diff --git a/.eslintrc b/.eslintrc
index 35d92042471a480ef2bfb832d34104b7531dd648..2c700b50916e10e7084e5fef6bd06282ce5136bc 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -7,7 +7,6 @@
     "page": true,
     "document": true
   },
-  "parser": "babel-eslint",
   "plugins": ["jest"],
   "rules": {
     "import/prefer-default-export": "off",
@@ -29,7 +28,16 @@
       "natural": false
     }],
     "react/jsx-props-no-spreading": "off",
+    "react/function-component-definition": "off",
+    "default-param-last": "off",
     "arrow-parens": "off",
-    "import/no-anonymous-default-export": "off"
+    "import/no-anonymous-default-export": "off",
+    "max-len": ["error", {
+      "code": 120,
+      "ignoreComments": true,
+      "ignoreStrings": true,
+      "ignoreTemplateLiterals": true,
+      "ignoreRegExpLiterals": true
+    }]
   }
 }
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
index fce1892d58ebbbaa6fe2d4159b2c0714c8e0e1c2..120b69a47a674634d7018a752b5af401d5681e83 100644
--- a/.github/workflows/node.js.yml
+++ b/.github/workflows/node.js.yml
@@ -12,13 +12,16 @@ on:
 jobs:
   build:
     runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: [14.x, 16.x, 17.x]
 
     steps:
     - uses: actions/checkout@v2
-    - name: Use Node.js 14.x
+    - name: Use Node.js ${{ matrix.node-version }}
       uses: actions/setup-node@v1
       with:
-        node-version: 14.x
+        node-version: ${{ matrix.node-version }}
     - run: npm install -g codecov
     - run: npm install
     - run: npm test
diff --git a/README.md b/README.md
index fa15415b22868f246d5ef0949962b3dde6a943c8..a1c91137160bb1a7ebaf52c21170a995fc1de240 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,14 @@ If you are interested in integrating Mirador with plugins into your project, we
 
 [https://github.com/ProjectMirador/mirador-integration](https://github.com/ProjectMirador/mirador-integration)
 
+If you want to simply embed Mirador in an HTML page without further customization, include the Mirador UMD build:
+
+```
+<script src="https://unpkg.com/mirador@latest/dist/mirador.min.js"></script>
+```
+
+More examples of embedding Mirador can be found at [https://github.com/ProjectMirador/mirador/wiki/M3-Embedding-in-Another-Environment#in-an-html-document-with-javascript](https://github.com/ProjectMirador/mirador/wiki/M3-Embedding-in-Another-Environment#in-an-html-document-with-javascript).
+
 ## Adding translations to Mirador
 For help with adding a translation, see [src/locales/README.md](src/locales/README.md)
 
diff --git a/__tests__/fixtures/version-2/related.json b/__tests__/fixtures/version-2/related.json
new file mode 100644
index 0000000000000000000000000000000000000000..f87bf96636c1b6e163afc209695426785d3427b8
--- /dev/null
+++ b/__tests__/fixtures/version-2/related.json
@@ -0,0 +1,16 @@
+{
+  "@context": "http://iiif.io/api/presentation/2/context.json",
+  "@id": "http://example.com/iiif/manifest/related-urls.json",
+  "@type": "sc:Manifest",
+  "related": [
+    "http://example.com/related1",
+    {
+      "@id": "http://example.com/related2"
+    },
+    {
+      "@id": "http://example.com/related3",
+      "format": "text/html",
+      "label": "Something related"
+    }
+  ]
+}
diff --git a/__tests__/integration/mirador/companion_windows.test.js b/__tests__/integration/mirador/companion_windows.test.js
index 97e6ca6fe473e490157b1c1dcd68843f5c175ca5..848a5f167c4cf9937f9de86707a2289193ba3e08 100644
--- a/__tests__/integration/mirador/companion_windows.test.js
+++ b/__tests__/integration/mirador/companion_windows.test.js
@@ -6,7 +6,7 @@ describe('Companion Windows', () => {
     await expect(page).toFill('#manifestURL', 'http://127.0.0.1:4488/__tests__/fixtures/version-2/001.json');
     await expect(page).toClick('#fetchBtn');
     await expect(page).toClick('[data-manifestid="http://127.0.0.1:4488/__tests__/fixtures/version-2/001.json"] button');
-    await page.waitFor(300);
+    await page.waitForTimeout(300);
     await expect(page).toMatchElement('.mirador-window');
   });
 
diff --git a/__tests__/integration/mirador/index.html b/__tests__/integration/mirador/index.html
index 43113f0074056a89e99fa96d4f61c093f238b3c1..9b531086dbac10a7355a4d21a093eff84e0f71a1 100644
--- a/__tests__/integration/mirador/index.html
+++ b/__tests__/integration/mirador/index.html
@@ -38,7 +38,7 @@
          { manifestId: "https://iiif.biblissima.fr/chateauroux/B360446201_MS0005/manifest.json", provider: "Biblissima"},
          { manifestId: "https://iiif.durham.ac.uk/manifests/trifle/32150/t1/m4/q7/t1m4q77fr328/manifest", provider: "Durham University Library"},
          //{ manifestId: "https://iiif.vam.ac.uk/collections/O1023003/manifest.json", provider: "Ocean liners"},
-         { manifestId: "https://zavicajna.digitalna.rs/iiif/iiif/api/presentation/3/96571949-03d6-478e-ab44-a2d5ad68f935%252F00000001%252Fostalo01%252F00000071/manifest", provider: "Библиотека 'Милутин Бојић'"},
+         { manifestId: "https://zavicajna.digitalna.rs/iiif/api/presentation/3/96571949-03d6-478e-ab44-a2d5ad68f935%252F00000001%252Fostalo01%252F00000071/manifest", provider: "Библиотека 'Милутин Бојић'"},
        ]
      });
     </script>
diff --git a/__tests__/integration/mirador/invalid-api-response.test.js b/__tests__/integration/mirador/invalid-api-response.test.js
index 5495d99a8facc38bd7826f6314c4e4aced2f8522..d0edd9de16a0702f5c1f3c14ad784f12079a09d4 100644
--- a/__tests__/integration/mirador/invalid-api-response.test.js
+++ b/__tests__/integration/mirador/invalid-api-response.test.js
@@ -8,7 +8,7 @@ describe('Mirador Invalid API Response Handler Test', () => {
     await page.evaluate(() => {
       document.querySelector('.mirador-add-resource-button').click();
     });
-    await page.waitFor(50);
+    await page.waitForTimeout(50);
     await expect(page).toFill('#manifestURL', uri);
 
     await expect(page).toClick('#fetchBtn');
@@ -25,16 +25,12 @@ describe('Mirador Invalid API Response Handler Test', () => {
   it('renders an error message when a manifest cannot be loaded (and allows it to be dismissed)', async () => {
     await fetchManifest('http://127.0.0.1:4488/__tests__/fixtures/version-2/broken');
 
-    await expect(page).toMatchElement(
-      'p', { text: 'The resource cannot be added:', timeout: 2000 },
-    );
-    await expect(page).toMatchElement(
-      'p', { text: 'http://127.0.0.1:4488/__tests__/fixtures/version-2/broken' },
-    );
+    await expect(page).toMatchElement('p', { text: 'The resource cannot be added:', timeout: 2000 });
+    await expect(page).toMatchElement('p', { text: 'http://127.0.0.1:4488/__tests__/fixtures/version-2/broken' });
 
     await expect(page).toClick('button', { text: 'Dismiss' });
 
-    await page.waitFor(() => !document.querySelector('li[data-manifestid="http://127.0.0.1:4488/__tests__/fixtures/version-2/broken"]'));
+    await page.waitForFunction(() => !document.querySelector('li[data-manifestid="http://127.0.0.1:4488/__tests__/fixtures/version-2/broken"]'));
 
     await expect(page).not.toMatchElement(
       'p',
diff --git a/__tests__/integration/mirador/language_switching.test.js b/__tests__/integration/mirador/language_switching.test.js
index e5f4b25f602bf2be43f9729ff7b9fe838fb7e705..58ceb88f9a60a753de1ad5837c4a29e32dff9e81 100644
--- a/__tests__/integration/mirador/language_switching.test.js
+++ b/__tests__/integration/mirador/language_switching.test.js
@@ -16,7 +16,7 @@ describe('Language Switching', () => {
       await expect(page).toMatchElement('[aria-label="Start Here"]');
       await expect(page).not.toMatchElement('[aria-label="Hier starten"]');
       await expect(page).toClick('li', { text: 'Deutsch' });
-      await page.waitFor(1000);
+      await page.waitForTimeout(1000);
       await expect(page).not.toMatchElement('[aria-label="Start Here"]');
       await expect(page).toMatchElement('[aria-label="Hier starten"]');
     });
diff --git a/__tests__/integration/mirador/plugins/add.test.js b/__tests__/integration/mirador/plugins/add.test.js
index 5e867d89e0c1592419952e60f85939014b7ccabe..878f81403c2f08704d4cdd2c40cf0da4deb35c01 100644
--- a/__tests__/integration/mirador/plugins/add.test.js
+++ b/__tests__/integration/mirador/plugins/add.test.js
@@ -2,7 +2,7 @@ describe('add two plugins to <WorkspaceControlPanelButtons>', () => {
   beforeAll(async () => {
     await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/plugins/add.html');
     await expect(page).toMatchElement('.mirador-viewer');
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
   });
 
   it('all add plugins will be added to <WorkspaceControlPanelButtons>', async () => {
diff --git a/__tests__/integration/mirador/plugins/companionWindow.test.js b/__tests__/integration/mirador/plugins/companionWindow.test.js
index d93f36e3abdf8c2bdd9645abd0ae94734b7f936d..71ad618bc9e7c3a981faab57aeda65a4885a7e70 100644
--- a/__tests__/integration/mirador/plugins/companionWindow.test.js
+++ b/__tests__/integration/mirador/plugins/companionWindow.test.js
@@ -2,13 +2,13 @@ describe('add plugins for companion windows', () => {
   beforeAll(async () => {
     await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/plugins/companionWindow.html');
     await expect(page).toMatchElement('.mirador-viewer');
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
   });
 
   it('added a plugin to the window sidebar and companion window', async () => {
     await expect(page).toClick('button[aria-label="Toggle sidebar"]');
 
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
     await expect(page).toMatchElement('.mirador-companion-window-left.mirador-window-sidebar-info-panel');
     await expect(page).toMatchElement('#add-plugin-companion-window-button');
 
diff --git a/__tests__/integration/mirador/plugins/priority.test.js b/__tests__/integration/mirador/plugins/priority.test.js
index 3ed451fb86ca96bc9af0fb14c9bd3c791e376991..6dfece29f863934b059ec503eb4c175340040e23 100644
--- a/__tests__/integration/mirador/plugins/priority.test.js
+++ b/__tests__/integration/mirador/plugins/priority.test.js
@@ -2,7 +2,7 @@ describe('try to apply 2 add plugins and 2 wrap plugins to <WorkspaceControlPane
   beforeAll(async () => {
     await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/plugins/priority.html');
     await expect(page).toMatchElement('.mirador-viewer');
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
   });
 
   it('only apply the first wrap plugin', async () => {
diff --git a/__tests__/integration/mirador/plugins/state.test.js b/__tests__/integration/mirador/plugins/state.test.js
index eafa59e8c29da51640d59e379458cf62d3264ee9..15e821ce441fbc9fc9cb3e5b1638ba894c326858 100644
--- a/__tests__/integration/mirador/plugins/state.test.js
+++ b/__tests__/integration/mirador/plugins/state.test.js
@@ -4,7 +4,7 @@ describe('how plugins relate to state', () => {
   beforeAll(async () => {
     await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/plugins/state.html');
     await expect(page).toMatchElement('.mirador-viewer');
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
   });
 
   it('plugin can read from state', async () => {
diff --git a/__tests__/integration/mirador/plugins/validate.test.js b/__tests__/integration/mirador/plugins/validate.test.js
index 311d2ec741f854d2a8d7f69f7bad3c805be604cf..d62a7c261cd39d43709a45aca81f4910ace16499 100644
--- a/__tests__/integration/mirador/plugins/validate.test.js
+++ b/__tests__/integration/mirador/plugins/validate.test.js
@@ -2,7 +2,7 @@ describe('pass valid and invalid plugins to <WorkspaceControlPanelButtons>', ()
   beforeAll(async () => {
     await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/plugins/validate.html');
     await expect(page).toMatchElement('.mirador-viewer');
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
   });
 
   it('valid plugins will be applied <WorkspaceControlPanelButtons>', async () => {
diff --git a/__tests__/integration/mirador/plugins/wrap.test.js b/__tests__/integration/mirador/plugins/wrap.test.js
index 77865efa64a3aa67c3ead55313c4dc96a4982344..2e77a1842b5b1b270fd3c11fb9031ecca6b5e7d5 100644
--- a/__tests__/integration/mirador/plugins/wrap.test.js
+++ b/__tests__/integration/mirador/plugins/wrap.test.js
@@ -2,7 +2,7 @@ describe('wrap <WorkspaceControlPanelButtons> by a plugin', () => {
   beforeAll(async () => {
     await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/plugins/wrap.html');
     await expect(page).toMatchElement('.mirador-viewer');
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
   });
 
   it('wraps <WorkspaceControlPanelButtons>', async () => {
diff --git a/__tests__/integration/mirador/thumbnail-navigation.test.js b/__tests__/integration/mirador/thumbnail-navigation.test.js
index 6b2cf8051f93b17a8989a7ab2a34a1270d7dd9f0..35de953ccaa3b13e04ec8ab4fb17bc7a9faf381e 100644
--- a/__tests__/integration/mirador/thumbnail-navigation.test.js
+++ b/__tests__/integration/mirador/thumbnail-navigation.test.js
@@ -12,7 +12,7 @@ describe('Thumbnail navigation', () => {
       miradorInstance.store.getState().windows
     ));
     expect(Object.values(windows)[0].canvasId).toBe('https://iiif.harvardartmuseums.org/manifests/object/299843/canvas/canvas-47174892'); // test harness in index.html starts at 2
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
     await expect(page).toClick('.mirador-thumbnail-nav-canvas-1 img');
     await expect(page).toMatchElement('.mirador-thumbnail-nav-canvas-1.mirador-current-canvas-grouping', { timeout: 1500 });
     windows = await page.evaluate(() => (
diff --git a/__tests__/integration/mirador/window_actions.test.js b/__tests__/integration/mirador/window_actions.test.js
index cc35d96dee1076da19efc6d340d1ff8e8795177b..8dd54ffba986ed101cc89dba80cb75823c64c40e 100644
--- a/__tests__/integration/mirador/window_actions.test.js
+++ b/__tests__/integration/mirador/window_actions.test.js
@@ -12,12 +12,12 @@ describe('Window actions', () => {
     await expect(page).toClick('[data-manifestid="http://127.0.0.1:4488/__tests__/fixtures/version-2/sn904cj3429.json"] button');
 
     await expect(page).toMatchElement('.mirador-window');
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
     await expect(page).toClick('.mirador-window-close');
     const numWindows = await page.evaluate(page => (
       document.querySelectorAll('.mirador-window').length
     )); // only default configed windows found
-    await page.waitFor(1000);
+    await page.waitForTimeout(1000);
     await expect(numWindows).toBe(0);
   });
 });
diff --git a/__tests__/src/components/AccessTokenSender.test.js b/__tests__/src/components/AccessTokenSender.test.js
index 33d56e0ada4c3a5f6bb0036847941ea784418a63..d14f10a556cb8d994a2c4bc1a2999fadb1b45753 100644
--- a/__tests__/src/components/AccessTokenSender.test.js
+++ b/__tests__/src/components/AccessTokenSender.test.js
@@ -20,7 +20,7 @@ describe('AccessTokenSender', () => {
 
   it('renders nothing if there is no url', () => {
     wrapper = createWrapper({});
-    expect(wrapper.matchesElement(<></>)).toBe(true);
+    expect(wrapper.isEmptyRender()).toBe(true);
   });
 
   it('renders properly', () => {
diff --git a/__tests__/src/components/LabelValueMetadata.test.js b/__tests__/src/components/LabelValueMetadata.test.js
index 23c279ff140fa029e8c8b8c0adad4d2d6468bab3..2e3178bab9a6b4d222861dc2a0cc3004b40f32c1 100644
--- a/__tests__/src/components/LabelValueMetadata.test.js
+++ b/__tests__/src/components/LabelValueMetadata.test.js
@@ -59,7 +59,7 @@ describe('LabelValueMetadata', () => {
 
     it('renders an empty fragment instead of an empty dl', () => {
       expect(wrapper.find('dl').length).toEqual(0);
-      expect(wrapper.matchesElement(<></>)).toBe(true);
+      expect(wrapper.isEmptyRender()).toBe(true);
     });
   });
 
diff --git a/__tests__/src/components/ManifestRelatedLinks.test.js b/__tests__/src/components/ManifestRelatedLinks.test.js
index 234b5bab9969c361ad9487a7f81e7a31307e4528..4bf7994f71580435d722be7eac01ab860077b0b8 100644
--- a/__tests__/src/components/ManifestRelatedLinks.test.js
+++ b/__tests__/src/components/ManifestRelatedLinks.test.js
@@ -21,6 +21,16 @@ describe('ManifestRelatedLinks', () => {
             },
           ]}
           manifestUrl="http://example.com/"
+          related={[
+            {
+              value: 'http://example.com/related',
+            },
+            {
+              format: 'video/ogg',
+              label: 'Video',
+              value: 'http://example.com/video',
+            },
+          ]}
           renderings={[
             {
               label: 'PDF Version',
@@ -81,16 +91,42 @@ describe('ManifestRelatedLinks', () => {
       ).toBe(true);
     });
 
-    it('renders manifest seeAlso information', () => {
+    it('renders related information', () => {
       expect(
         wrapper.find(Typography).at(5)
           .matchesElement(
-            <Typography component="dt">iiif_seeAlso</Typography>,
+            <Typography component="dt">iiif_related</Typography>,
           ),
       ).toBe(true);
 
       expect(
         wrapper.find(Typography).at(6)
+          .matchesElement(
+            <Typography component="dd"><Link href="http://example.com/related">http://example.com/related</Link></Typography>,
+          ),
+      ).toBe(true);
+
+      expect(
+        wrapper.find(Typography).at(7)
+          .matchesElement(
+            <Typography component="dd">
+              <Link href="http://example.com/video">Video</Link>
+              <Typography>(video/ogg)</Typography>
+            </Typography>,
+          ),
+      ).toBe(true);
+    });
+
+    it('renders manifest seeAlso information', () => {
+      expect(
+        wrapper.find(Typography).at(9)
+          .matchesElement(
+            <Typography component="dt">iiif_seeAlso</Typography>,
+          ),
+      ).toBe(true);
+
+      expect(
+        wrapper.find(Typography).at(10)
           .matchesElement(
             <Typography component="dd">
               <Link href="http://example.com/a">A</Link>
@@ -100,7 +136,7 @@ describe('ManifestRelatedLinks', () => {
       ).toBe(true);
 
       expect(
-        wrapper.find(Typography).at(8)
+        wrapper.find(Typography).at(12)
           .matchesElement(
             <Typography component="dd"><Link href="http://example.com/b">http://example.com/b</Link></Typography>,
           ),
@@ -109,14 +145,14 @@ describe('ManifestRelatedLinks', () => {
 
     it('renders manifest links', () => {
       expect(
-        wrapper.find(Typography).at(9)
+        wrapper.find(Typography).at(13)
           .matchesElement(
             <Typography component="dt">iiif_manifest</Typography>,
           ),
       ).toBe(true);
 
       expect(
-        wrapper.find(Typography).at(10)
+        wrapper.find(Typography).at(14)
           .matchesElement(
             <Typography component="dd"><Link href="http://example.com/">http://example.com/</Link></Typography>,
           ),
diff --git a/__tests__/src/components/MiradorMenuButton.test.js b/__tests__/src/components/MiradorMenuButton.test.js
index da0178315ea735140e548ba9a6ffa506183524d0..ad865d63dab0cd05901265415cc6d263618c5ea0 100644
--- a/__tests__/src/components/MiradorMenuButton.test.js
+++ b/__tests__/src/components/MiradorMenuButton.test.js
@@ -11,7 +11,7 @@ import { MiradorMenuButton } from '../../../src/components/MiradorMenuButton';
 function createWrapper(props) {
   return shallow(
     <MiradorMenuButton aria-label="The Label" containerId="mirador" {...props}>
-      <>icon</>
+      icon
     </MiradorMenuButton>,
   );
 }
diff --git a/__tests__/src/components/NestedMenu.test.js b/__tests__/src/components/NestedMenu.test.js
index f6c49ba3cccc8b3865a1aefef80d8e5b953effa9..6db2b464cfb440374d01469df7be36e53c43ddbb 100644
--- a/__tests__/src/components/NestedMenu.test.js
+++ b/__tests__/src/components/NestedMenu.test.js
@@ -13,11 +13,11 @@ import { NestedMenu } from '../../../src/components/NestedMenu';
 function createWrapper(props) {
   return shallow(
     <NestedMenu
-      icon={<>GivenIcon</>}
+      icon="GivenIcon"
       label="GivenLabel"
       {...props}
     >
-      <>GivenChildren</>
+      GivenChildren
     </NestedMenu>,
   );
 }
diff --git a/__tests__/src/components/OpenSeadragonViewer.test.js b/__tests__/src/components/OpenSeadragonViewer.test.js
index 7dfa0aae873b665d221d505927f71ee30034ec2b..1c957824c3ece5fbe987c8146b10bd258d1f0b2b 100644
--- a/__tests__/src/components/OpenSeadragonViewer.test.js
+++ b/__tests__/src/components/OpenSeadragonViewer.test.js
@@ -10,46 +10,52 @@ const canvases = Utils.parseManifest(fixture).getSequences()[0].getCanvases();
 
 jest.mock('openseadragon');
 
+/**
+ * Helper function to create a shallow wrapper around OpenSeadragonViewer
+ */
+function createWrapper(props) {
+  return shallow(
+    <OpenSeadragonViewer
+      classes={{}}
+      infoResponses={[{
+        id: 'a',
+        json: {
+          '@id': 'http://foo',
+          height: 200,
+          width: 100,
+        },
+      }, {
+        id: 'b',
+        json: {
+          '@id': 'http://bar',
+          height: 201,
+          width: 150,
+        },
+      }]}
+      nonTiledImages={[{
+        getProperty: () => {},
+        id: 'http://foo',
+      }]}
+      windowId="base"
+      config={{}}
+      updateViewport={jest.fn()}
+      t={k => k}
+      canvasWorld={new CanvasWorld(canvases)}
+      {...props}
+    >
+      <div className="foo" />
+      <div className="bar" />
+    </OpenSeadragonViewer>,
+  );
+}
+
 describe('OpenSeadragonViewer', () => {
   let wrapper;
   let updateViewport;
   beforeEach(() => {
     OpenSeadragon.mockClear();
-
-    updateViewport = jest.fn();
-
-    wrapper = shallow(
-      <OpenSeadragonViewer
-        classes={{}}
-        infoResponses={[{
-          id: 'a',
-          json: {
-            '@id': 'http://foo',
-            height: 200,
-            width: 100,
-          },
-        }, {
-          id: 'b',
-          json: {
-            '@id': 'http://bar',
-            height: 201,
-            width: 150,
-          },
-        }]}
-        nonTiledImages={[{
-          getProperty: () => {},
-          id: 'http://foo',
-        }]}
-        windowId="base"
-        config={{}}
-        updateViewport={updateViewport}
-        t={k => k}
-        canvasWorld={new CanvasWorld(canvases)}
-      >
-        <div className="foo" />
-        <div className="bar" />
-      </OpenSeadragonViewer>,
-    );
+    wrapper = createWrapper({});
+    updateViewport = wrapper.instance().props.updateViewport;
   });
   it('renders the component', () => {
     expect(wrapper.find('.mirador-osd-container').length).toBe(1);
@@ -70,10 +76,7 @@ describe('OpenSeadragonViewer', () => {
       expect(wrapper.instance().infoResponsesMatch([])).toBe(false);
     });
     it('with an empty array', () => {
-      wrapper.instance().viewer = {
-        close: () => {},
-      };
-      wrapper.setProps({ infoResponses: [] });
+      wrapper = createWrapper({ infoResponses: [] });
       expect(wrapper.instance().infoResponsesMatch([])).toBe(true);
     });
     it('when the @ids do match', () => {
@@ -87,7 +90,7 @@ describe('OpenSeadragonViewer', () => {
       expect(wrapper.instance().infoResponsesMatch([{ id: 'a', json: { '@id': 'http://foo-degraded' } }])).toBe(false);
     });
     it('when the id props match', () => {
-      wrapper.setProps({
+      wrapper = createWrapper({
         infoResponses: [{
           id: 'a',
           json: {
@@ -106,10 +109,7 @@ describe('OpenSeadragonViewer', () => {
       expect(wrapper.instance().nonTiledImagedMatch([])).toBe(false);
     });
     it('with an empty array', () => {
-      wrapper.instance().viewer = {
-        close: () => {},
-      };
-      wrapper.setProps({ nonTiledImages: [] });
+      wrapper = createWrapper({ nonTiledImages: [] });
       expect(wrapper.instance().nonTiledImagedMatch([])).toBe(true);
     });
     it('when the ids do match', () => {
@@ -118,21 +118,17 @@ describe('OpenSeadragonViewer', () => {
   });
 
   describe('addAllImageSources', () => {
-    it('calls addTileSource for every tileSources and then zoomsToWorld', () => {
-      wrapper.instance().viewer = {
-        close: () => {},
-      };
-      wrapper.setProps({ infoResponses: [1, 2, 3, 4] });
+    it('calls addTileSource for every tileSources and then zoomsToWorld', async () => {
+      wrapper = createWrapper({ infoResponses: [1, 2, 3, 4] });
+      wrapper.setState({ viewer: { viewport: { fitBounds: () => {} }, world: { getItemCount: () => 0 } } });
       const mockAddTileSource = jest.fn();
       wrapper.instance().addTileSource = mockAddTileSource;
-      wrapper.instance().addAllImageSources();
+      await wrapper.instance().addAllImageSources();
       expect(mockAddTileSource).toHaveBeenCalledTimes(4);
     });
-    it('calls addNonTileSource for every nonTiledImage and then zoomsToWorld', () => {
-      wrapper.instance().viewer = {
-        close: () => {},
-      };
-      wrapper.setProps({
+
+    it('calls addNonTileSource for every nonTiledImage and then zoomsToWorld', async () => {
+      wrapper = createWrapper({
         nonTiledImages: [
           { getProperty: () => 'Image' },
           { getProperty: () => 'Image' },
@@ -140,22 +136,18 @@ describe('OpenSeadragonViewer', () => {
           { getProperty: () => 'Image' },
         ],
       });
+      const instance = wrapper.instance();
       const mockAddNonTiledImage = jest.fn();
       wrapper.instance().addNonTiledImage = mockAddNonTiledImage;
-      wrapper.instance().addAllImageSources();
+      await instance.addAllImageSources();
       expect(mockAddNonTiledImage).toHaveBeenCalledTimes(4);
     });
   });
 
   describe('addTileSource', () => {
-    it('calls addTiledImage asynchronously on the OSD viewer', async () => {
-      wrapper.instance().addTileSource({}).then((event) => {
-        expect(event).toBe('event');
-      });
-    });
-    it('when a viewer is not available, returns an unresolved Promise', () => {
-      expect(wrapper.instance().addTileSource({})).toEqual(expect.any(Promise));
-    });
+    it('when a viewer is not available, returns an unresolved Promise', () => (
+      expect(wrapper.instance().addTileSource({})).rejects.toBeUndefined()
+    ));
   });
 
   describe('addNonTiledImage', () => {
@@ -189,17 +181,15 @@ describe('OpenSeadragonViewer', () => {
         layerIndexOfImageResource: i => 1 - i,
         layerOpacityOfImageResource: i => 0.5,
       };
-      wrapper.setProps({ canvasWorld });
+      wrapper = createWrapper({ canvasWorld });
       wrapper.instance().loaded = true;
-      wrapper.setState({
-        viewer: {
-          world: {
-            getItemAt: i => ({ setOpacity, source: { id: i } }),
-            getItemCount: () => 2,
-            setItemIndex,
-          },
+      wrapper.instance().state.viewer = {
+        world: {
+          getItemAt: i => ({ setOpacity, source: { id: i } }),
+          getItemCount: () => 2,
+          setItemIndex,
         },
-      });
+      };
 
       wrapper.instance().refreshTileProperties();
 
@@ -280,12 +270,8 @@ describe('OpenSeadragonViewer', () => {
     it('calls the OSD viewport panTo and zoomTo with the component state', () => {
       wrapper.instance().componentDidMount();
 
-      expect(panTo).toHaveBeenCalledWith(
-        { x: 1, y: 0, zoom: 0.5 }, true,
-      );
-      expect(zoomTo).toHaveBeenCalledWith(
-        0.5, { x: 1, y: 0, zoom: 0.5 }, true,
-      );
+      expect(panTo).toHaveBeenCalledWith({ x: 1, y: 0, zoom: 0.5 }, true);
+      expect(zoomTo).toHaveBeenCalledWith(0.5, { x: 1, y: 0, zoom: 0.5 }, true);
     });
 
     it('adds animation-start/finish flag for rerendering performance', () => {
@@ -340,12 +326,8 @@ describe('OpenSeadragonViewer', () => {
         },
       });
 
-      expect(panTo).toHaveBeenCalledWith(
-        expect.objectContaining({ x: 1, y: 0, zoom: 0.5 }), false,
-      );
-      expect(zoomTo).toHaveBeenCalledWith(
-        0.5, expect.objectContaining({ x: 1, y: 0, zoom: 0.5 }), false,
-      );
+      expect(panTo).toHaveBeenCalledWith(expect.objectContaining({ x: 1, y: 0, zoom: 0.5 }), false);
+      expect(zoomTo).toHaveBeenCalledWith(0.5, expect.objectContaining({ x: 1, y: 0, zoom: 0.5 }), false);
       expect(setRotation).toHaveBeenCalledWith(90);
       expect(setFlip).toHaveBeenCalledWith(true);
       expect(forceRedraw).not.toHaveBeenCalled();
diff --git a/__tests__/src/components/ScrollTo.test.js b/__tests__/src/components/ScrollTo.test.js
index b6c7953106d6fff824bbf86bd484f375a1f49c7c..ecb4a6d3f7de6891e958139b7ca8c0e95ebac435 100644
--- a/__tests__/src/components/ScrollTo.test.js
+++ b/__tests__/src/components/ScrollTo.test.js
@@ -10,7 +10,7 @@ function createWrapper(props) {
       scrollTo
       {...props}
     >
-      <>Child Prop</>
+      Child Prop
     </ScrollTo>,
   );
 }
diff --git a/__tests__/src/components/SearchPanelControls.test.js b/__tests__/src/components/SearchPanelControls.test.js
index d09f641230cbb66e5cc7dcec22eaa22c2e3710db..64c57780ca19c57ccfa76ff0b03ff1aa340eca34 100644
--- a/__tests__/src/components/SearchPanelControls.test.js
+++ b/__tests__/src/components/SearchPanelControls.test.js
@@ -36,9 +36,7 @@ describe('SearchPanelControls', () => {
     const value = 'somestring';
     wrapper.find(Autocomplete).prop('onChange')({}, { match: value }, {});
     expect(wrapper.state().search).toEqual(value);
-    expect(fetchSearch).toHaveBeenCalledWith(
-      'window', 'cw', 'http://example.com/search?q=somestring', 'somestring',
-    );
+    expect(fetchSearch).toHaveBeenCalledWith('window', 'cw', 'http://example.com/search?q=somestring', 'somestring');
   });
   it('renders a text input through the renderInput prop', () => {
     const wrapper = createWrapper();
diff --git a/__tests__/src/components/SidebarIndexTableOfContents.test.js b/__tests__/src/components/SidebarIndexTableOfContents.test.js
index 15c3729ea513c20f2fd7c09197d20262e90e17aa..c30044db7e6b9a0028151acafd6bdba79d3f524b 100644
--- a/__tests__/src/components/SidebarIndexTableOfContents.test.js
+++ b/__tests__/src/components/SidebarIndexTableOfContents.test.js
@@ -95,7 +95,7 @@ describe('SidebarIndexTableOfContents', () => {
 
   it('toggles branch nodes on click, but not leaf nodes', () => {
     const wrapper = createWrapper({ setCanvas, toggleNode });
-    const treeView = wrapper.children(TreeView).at(0);
+    const treeView = wrapper.find(TreeView);
     const node0 = treeView.childAt(0).childAt(0);
     expect(node0.prop('nodeId')).toBe('0-0');
     node0.simulate('click');
@@ -120,7 +120,7 @@ describe('SidebarIndexTableOfContents', () => {
       setCanvas,
       toggleNode,
     });
-    const treeView = wrapper.children(TreeView).at(0);
+    const treeView = wrapper.find(TreeView);
 
     const node0 = treeView.childAt(0).childAt(0);
     expect(node0.prop('nodeId')).toBe('0-0');
@@ -143,7 +143,7 @@ describe('SidebarIndexTableOfContents', () => {
       setCanvas,
       toggleNode,
     });
-    const treeView = wrapper.children(TreeView).at(0);
+    const treeView = wrapper.find(TreeView);
     const node0 = treeView.childAt(0).childAt(0);
     expect(node0.prop('nodeId')).toBe('0-0');
     const node00 = node0.children().at(0).childAt(0);
@@ -161,7 +161,7 @@ describe('SidebarIndexTableOfContents', () => {
 
   it('toggles branch nodes (but not leaf nodes) with Space or Enter key', () => {
     const wrapper = createWrapper({ setCanvas, toggleNode });
-    const treeView = wrapper.children(TreeView).at(0);
+    const treeView = wrapper.find(TreeView);
     const node0 = treeView.childAt(0).childAt(0);
     node0.simulate(...createKeydownProps('Enter'));
     expect(toggleNode).toHaveBeenCalledTimes(1);
@@ -180,7 +180,7 @@ describe('SidebarIndexTableOfContents', () => {
 
   it('calls setCanvas only on click for ranges with canvases that do not have children', () => {
     const wrapper = createWrapper({ setCanvas, toggleNode });
-    const treeView = wrapper.children(TreeView).at(0);
+    const treeView = wrapper.find(TreeView);
     const node0 = treeView.childAt(0).childAt(0);
     expect(node0.prop('nodeId')).toBe('0-0');
     node0.simulate('click');
@@ -204,7 +204,7 @@ describe('SidebarIndexTableOfContents', () => {
       toggleNode,
       windowId: 'w1',
     });
-    const treeView = version2wrapper.children(TreeView).at(0);
+    const treeView = version2wrapper.find(TreeView);
     const node3 = treeView.childAt(3).childAt(0);
     expect(node3.prop('nodeId')).toBe('0-3');
     node3.simulate('click');
@@ -216,7 +216,7 @@ describe('SidebarIndexTableOfContents', () => {
       toggleNode,
       windowId: 'w1',
     });
-    const treeViewVersion3 = version3wrapper.children(TreeView).at(0);
+    const treeViewVersion3 = version3wrapper.find(TreeView);
     const rootNode = treeViewVersion3.childAt(0).childAt(0);
     const version3node1 = rootNode.childAt(1).childAt(0);
     expect(version3node1.prop('nodeId')).toBe('0-0-1');
@@ -236,7 +236,7 @@ describe('SidebarIndexTableOfContents', () => {
 
   it('does not select a canvas when opening a node with the right arrow key', () => {
     const wrapper = createWrapper({ setCanvas, toggleNode });
-    const treeView = wrapper.children(TreeView).at(0);
+    const treeView = wrapper.find(TreeView);
     const node0 = treeView.childAt(0).childAt(0);
     expect(node0.prop('nodeId')).toBe('0-0');
     node0.simulate(...createKeydownProps('ArrowRight'));
@@ -246,7 +246,7 @@ describe('SidebarIndexTableOfContents', () => {
 
   it('does not select a canvas when closing a node with the left arrow key', () => {
     const wrapper = createWrapper({ expandedNodeIds: ['0-0'], setCanvas, toggleNode });
-    const treeView = wrapper.children(TreeView).at(0);
+    const treeView = wrapper.find(TreeView);
     const node0 = treeView.childAt(0).childAt(0);
     expect(node0.prop('nodeId')).toBe('0-0');
     node0.simulate(...createKeydownProps('ArrowLeft'));
diff --git a/__tests__/src/components/ThumbnailNavigation.test.js b/__tests__/src/components/ThumbnailNavigation.test.js
index 24940223925a16632dbd0a6dcbca8d0a7defd39a..67173837815e7b127784a502db0a22ac08732053 100644
--- a/__tests__/src/components/ThumbnailNavigation.test.js
+++ b/__tests__/src/components/ThumbnailNavigation.test.js
@@ -100,6 +100,12 @@ describe('ThumbnailNavigation', () => {
       expect(wrapper.instance().areaHeight()).toEqual(150);
       expect(rightWrapper.instance().areaHeight(99)).toEqual(99);
     });
+    describe('without any canvases', () => {
+      it('returns the default for the calculated size', () => {
+        wrapper = createWrapper({ canvasGroupings: new CanvasGroupings([]).groupings() });
+        expect(wrapper.instance().calculateScaledSize(0)).toEqual(108);
+      });
+    });
   });
   describe('keyboard navigation', () => {
     const setNextCanvas = jest.fn();
diff --git a/__tests__/src/components/Window.test.js b/__tests__/src/components/Window.test.js
index c16b361fef230d10765295efaccef0786e4ec971..78bd9f3884e3690e29f85f8d67361bb369e4ffec 100644
--- a/__tests__/src/components/Window.test.js
+++ b/__tests__/src/components/Window.test.js
@@ -47,18 +47,14 @@ describe('Window', () => {
   describe('when workspaceType is mosaic', () => {
     xit('calls the context mosaicWindowActions connectDragSource method to make WindowTopBar draggable', () => {
       const connectDragSource = jest.fn(component => component);
-      wrapper = createWrapper(
-        { windowDraggable: true, workspaceType: 'mosaic' }, { mosaicWindowActions: { connectDragSource } },
-      );
+      wrapper = createWrapper({ windowDraggable: true, workspaceType: 'mosaic' }, { mosaicWindowActions: { connectDragSource } });
       expect(wrapper.find(WindowTopBar)).toHaveLength(1);
       expect(connectDragSource).toHaveBeenCalled();
     });
 
     it('does not call the context mosaicWindowActions connectDragSource when the windowDraggable is set to false', () => {
       const connectDragSource = jest.fn(component => component);
-      wrapper = createWrapper(
-        { windowDraggable: false, workspaceType: 'mosaic' }, { mosaicWindowActions: { connectDragSource } },
-      );
+      wrapper = createWrapper({ windowDraggable: false, workspaceType: 'mosaic' }, { mosaicWindowActions: { connectDragSource } });
       expect(wrapper.find(WindowTopBar)).toHaveLength(1);
       expect(connectDragSource).not.toHaveBeenCalled();
     });
diff --git a/__tests__/src/components/WindowTopBarPluginMenu.test.js b/__tests__/src/components/WindowTopBarPluginMenu.test.js
index 436019f42c32ebe0ab8cef43a0e3141445a412aa..c27ef214d9867e68ce60e042f83bc09e827cfe19 100644
--- a/__tests__/src/components/WindowTopBarPluginMenu.test.js
+++ b/__tests__/src/components/WindowTopBarPluginMenu.test.js
@@ -21,12 +21,9 @@ describe('WindowTopBarPluginMenu', () => {
   let wrapper;
 
   describe('when there are no plugins present', () => {
-    it('renders a Fragment (and no Button/Menu/PluginHook)', () => {
+    it('renders nothing (and no Button/Menu/PluginHook)', () => {
       wrapper = createWrapper();
-      expect(wrapper.find('Fragment').length).toBe(1);
-      expect(wrapper.find(Menu).length).toBe(0);
-      expect(wrapper.find(MiradorMenuButton).length).toBe(0);
-      expect(wrapper.find(PluginHook).length).toBe(0);
+      expect(wrapper.isEmptyRender()).toBe(true);
     });
   });
 
diff --git a/__tests__/src/lib/MiradorViewer.test.js b/__tests__/src/lib/MiradorViewer.test.js
index f3f2702b66bfb4d40bcdf3e0851332257b56ae49..500ddff3b4979e7c7d272f05e994b132d220a7f2 100644
--- a/__tests__/src/lib/MiradorViewer.test.js
+++ b/__tests__/src/lib/MiradorViewer.test.js
@@ -7,12 +7,19 @@ jest.mock('react-dom');
 jest.mock('isomorphic-unfetch', () => jest.fn(() => Promise.resolve({ json: () => ({}) })));
 
 describe('MiradorViewer', () => {
+  let container;
   let instance;
   beforeAll(() => {
+    container = document.createElement('div');
+    container.id = 'mirador';
+    document.body.appendChild(container);
     ReactDOM.render = jest.fn();
     ReactDOM.unmountComponentAtNode = jest.fn();
     instance = new MiradorViewer({ id: 'mirador' });
   });
+  afterAll(() => {
+    document.body.removeChild(container);
+  });
   describe('constructor', () => {
     it('returns viewer store', () => {
       expect(instance.store.dispatch).toBeDefined();
@@ -23,32 +30,34 @@ describe('MiradorViewer', () => {
   });
   describe('processConfig', () => {
     it('transforms config values to actions to dispatch to store', () => {
-      instance = new MiradorViewer({
-        catalog: [
-          { manifestId: 'http://media.nga.gov/public/manifests/nga_highlights.json', provider: 'National Gallery of Art' },
-        ],
-        id: 'mirador',
-        windows: [
-          {
-            canvasId: 'https://iiif.harvardartmuseums.org/manifests/object/299843/canvas/canvas-47174892',
-            loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843',
-            thumbnailNavigationPosition: 'far-bottom',
-          },
-          {
-            loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843',
-            view: 'book',
-          },
-        ],
-      },
-      {
-        plugins: [{
-          config: {
-            foo: 'bar',
-          },
-          mode: 'add',
-          target: 'WindowTopBarPluginArea',
-        }],
-      });
+      instance = new MiradorViewer(
+        {
+          catalog: [
+            { manifestId: 'http://media.nga.gov/public/manifests/nga_highlights.json', provider: 'National Gallery of Art' },
+          ],
+          id: 'mirador',
+          windows: [
+            {
+              canvasId: 'https://iiif.harvardartmuseums.org/manifests/object/299843/canvas/canvas-47174892',
+              loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843',
+              thumbnailNavigationPosition: 'far-bottom',
+            },
+            {
+              loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843',
+              view: 'book',
+            },
+          ],
+        },
+        {
+          plugins: [{
+            config: {
+              foo: 'bar',
+            },
+            mode: 'add',
+            target: 'WindowTopBarPluginArea',
+          }],
+        },
+      );
 
       const { windows, catalog, config } = instance.store.getState();
       const windowIds = Object.keys(windows);
@@ -67,35 +76,37 @@ describe('MiradorViewer', () => {
       expect(config.foo).toBe('bar');
     });
     it('merges translation configs from multiple plugins', () => {
-      instance = new MiradorViewer({
-        id: 'mirador',
-      },
-      {
-        plugins: [
-          {
-            config: {
-              translations: {
-                en: {
-                  foo: 'bar',
+      instance = new MiradorViewer(
+        {
+          id: 'mirador',
+        },
+        {
+          plugins: [
+            {
+              config: {
+                translations: {
+                  en: {
+                    foo: 'bar',
+                  },
                 },
               },
+              mode: 'add',
+              target: 'WindowTopBarPluginArea',
             },
-            mode: 'add',
-            target: 'WindowTopBarPluginArea',
-          },
-          {
-            config: {
-              translations: {
-                en: {
-                  bat: 'bar',
+            {
+              config: {
+                translations: {
+                  en: {
+                    bat: 'bar',
+                  },
                 },
               },
+              mode: 'wrap',
+              target: 'Window',
             },
-            mode: 'wrap',
-            target: 'Window',
-          },
-        ],
-      });
+          ],
+        },
+      );
 
       const { config } = instance.store.getState();
 
diff --git a/__tests__/src/sagas/windows.test.js b/__tests__/src/sagas/windows.test.js
index 796c0adc0072fecef6b2290c6bdd649232d08d32..126a381625da55b0e2e9bef4e38053f7c8ceb908 100644
--- a/__tests__/src/sagas/windows.test.js
+++ b/__tests__/src/sagas/windows.test.js
@@ -218,8 +218,10 @@ describe('window-level sagas', () => {
 
       return expectSaga(setCurrentAnnotationsOnCurrentCanvas, action)
         .provide([
-          [select(getSearchForWindow,
-            { windowId: 'abc123' }), {}],
+          [select(
+            getSearchForWindow,
+            { windowId: 'abc123' },
+          ), {}],
         ])
         .run()
         .then(({ allEffects }) => allEffects.length === 0);
@@ -234,8 +236,10 @@ describe('window-level sagas', () => {
 
       return expectSaga(setCurrentAnnotationsOnCurrentCanvas, action)
         .provide([
-          [select(getSearchForWindow,
-            { windowId: 'abc123' }), { cwid: { } }],
+          [select(
+            getSearchForWindow,
+            { windowId: 'abc123' },
+          ), { cwid: { } }],
           [select(getAnnotationsBySearch, { canvasIds: ['a', 'b'], companionWindowIds: ['cwid'], windowId: 'abc123' }),
             { }],
         ])
@@ -256,8 +260,10 @@ describe('window-level sagas', () => {
 
       return expectSaga(setCurrentAnnotationsOnCurrentCanvas, action)
         .provide([
-          [select(getSearchForWindow,
-            { windowId: 'abc123' }), { cwid: { } }],
+          [select(
+            getSearchForWindow,
+            { windowId: 'abc123' },
+          ), { cwid: { } }],
           [select(getAnnotationsBySearch, { canvasIds: ['a', 'b'], companionWindowIds: ['cwid'], windowId: 'abc123' }),
             { cwid: ['annoId'] }],
         ])
diff --git a/__tests__/src/selectors/canvases.test.js b/__tests__/src/selectors/canvases.test.js
index e8fc9adb8ce1f84de636ddfc70c2b75d7896de70..43d1a0f735df6545bf35ed622274b99498ea1483 100644
--- a/__tests__/src/selectors/canvases.test.js
+++ b/__tests__/src/selectors/canvases.test.js
@@ -355,9 +355,7 @@ describe('getVisibleCanvasNonTiledResources', () => {
         },
       },
     };
-    expect(getVisibleCanvasNonTiledResources(
-      state, { windowId: 'a' },
-    )[0].id).toBe('http://iiif.io/api/presentation/2.0/example/fixtures/resources/page1-full.png');
+    expect(getVisibleCanvasNonTiledResources(state, { windowId: 'a' })[0].id).toBe('http://iiif.io/api/presentation/2.0/example/fixtures/resources/page1-full.png');
   });
   it('works for v3 Presentation API', () => {
     const state = {
@@ -376,9 +374,7 @@ describe('getVisibleCanvasNonTiledResources', () => {
         },
       },
     };
-    expect(getVisibleCanvasNonTiledResources(
-      state, { windowId: 'a' },
-    )[0].id).toBe('http://iiif.io/api/presentation/2.1/example/fixtures/resources/page1-full.png');
+    expect(getVisibleCanvasNonTiledResources(state, { windowId: 'a' })[0].id).toBe('http://iiif.io/api/presentation/2.1/example/fixtures/resources/page1-full.png');
   });
 
   describe('getVisibleCanvasVideoResources', () => {
@@ -399,9 +395,7 @@ describe('getVisibleCanvasNonTiledResources', () => {
           },
         },
       };
-      expect(getVisibleCanvasVideoResources(
-        state, { windowId: 'a' },
-      )[0].id).toBe('https://fixtures.iiif.io/video/indiana/30-minute-clock/medium/30-minute-clock.mp4');
+      expect(getVisibleCanvasVideoResources(state, { windowId: 'a' })[0].id).toBe('https://fixtures.iiif.io/video/indiana/30-minute-clock/medium/30-minute-clock.mp4');
     });
   });
 
@@ -423,9 +417,7 @@ describe('getVisibleCanvasNonTiledResources', () => {
           },
         },
       };
-      expect(getVisibleCanvasCaptions(
-        state, { windowId: 'a' },
-      )[0].id).toBe('https://example.com/file.vtt');
+      expect(getVisibleCanvasCaptions(state, { windowId: 'a' })[0].id).toBe('https://example.com/file.vtt');
     });
   });
 
@@ -447,9 +439,7 @@ describe('getVisibleCanvasNonTiledResources', () => {
           },
         },
       };
-      expect(getVisibleCanvasAudioResources(
-        state, { windowId: 'a' },
-      )[0].id).toBe('https://fixtures.iiif.io/audio/indiana/mahler-symphony-3/CD1/medium/128Kbps.mp4');
+      expect(getVisibleCanvasAudioResources(state, { windowId: 'a' })[0].id).toBe('https://fixtures.iiif.io/audio/indiana/mahler-symphony-3/CD1/medium/128Kbps.mp4');
     });
   });
 });
diff --git a/__tests__/src/selectors/manifests.test.js b/__tests__/src/selectors/manifests.test.js
index 3c174506f35b8e0969d177dae3c8652e885a4231..28b0461ad54b9d46f89e5b30f2f8751134de346e 100644
--- a/__tests__/src/selectors/manifests.test.js
+++ b/__tests__/src/selectors/manifests.test.js
@@ -6,6 +6,7 @@ import manifestFixtureSn904cj3429 from '../../fixtures/version-2/sn904cj3429.jso
 import manifestFixturev3001 from '../../fixtures/version-3/001.json';
 import manifestFixtureWithAProvider from '../../fixtures/version-3/with_a_provider.json';
 import manifestFixtureFg165hz3589 from '../../fixtures/version-2/fg165hz3589.json';
+import manifestFixtureRelated from '../../fixtures/version-2/related.json';
 import {
   getManifestoInstance,
   getManifestLocale,
@@ -18,8 +19,10 @@ import {
   getManifestTitle,
   getManifestThumbnail,
   getManifestMetadata,
+  getManifestRelated,
   getManifestRelatedContent,
   getManifestRenderings,
+  getManifestSeeAlso,
   getManifestUrl,
   getMetadataLocales,
   getRequiredStatement,
@@ -210,6 +213,33 @@ describe('getManifestRenderings', () => {
   });
 });
 
+describe('getManifestRelated', () => {
+  it('should return manifest related', () => {
+    const state = { manifests: { x: { json: manifestFixtureRelated } } };
+    const received = getManifestRelated(state, { manifestId: 'x' });
+    expect(received).toEqual([
+      {
+        value: 'http://example.com/related1',
+      },
+      {
+        format: undefined,
+        label: null,
+        value: 'http://example.com/related2',
+      },
+      {
+        format: 'text/html',
+        label: 'Something related',
+        value: 'http://example.com/related3',
+      },
+    ]);
+  });
+
+  it('should return undefined if manifest undefined', () => {
+    const received = getManifestRelated({ manifests: {} }, { manifestId: 'x' });
+    expect(received).toBeUndefined();
+  });
+});
+
 describe('getManifestRelatedContent', () => {
   it('should return manifest seeAlso content', () => {
     const state = { manifests: { x: { json: manifestFixtureSn904cj3429 } } };
@@ -229,6 +259,25 @@ describe('getManifestRelatedContent', () => {
   });
 });
 
+describe('getManifestSeeAlso', () => {
+  it('should return manifest seeAlso content', () => {
+    const state = { manifests: { x: { json: manifestFixtureSn904cj3429 } } };
+    const received = getManifestSeeAlso(state, { manifestId: 'x' });
+    expect(received).toEqual([
+      {
+        format: 'application/mods+xml',
+        label: null,
+        value: 'https://purl.stanford.edu/sn904cj3429.mods',
+      },
+    ]);
+  });
+
+  it('should return undefined if manifest undefined', () => {
+    const received = getManifestSeeAlso({ manifests: {} }, { manifestId: 'x' });
+    expect(received).toBeUndefined();
+  });
+});
+
 describe('getManifestUrl', () => {
   it('should return manifest url', () => {
     const state = { manifests: { x: { json: manifestFixtureWithAProvider } } };
diff --git a/__tests__/src/selectors/searches.test.js b/__tests__/src/selectors/searches.test.js
index 3a9e3791a4e3e215d79cb17419d0785c21ea6a0e..378417caf92aed1c363bb59fe3bbc8365891669f 100644
--- a/__tests__/src/selectors/searches.test.js
+++ b/__tests__/src/selectors/searches.test.js
@@ -309,9 +309,7 @@ describe('getResourceAnnotationForSearchHit', () => {
     };
 
     expect(
-      getResourceAnnotationForSearchHit(
-        state, { annotationUri: annoId, companionWindowId, windowId: 'a' },
-      ).resource['@id'],
+      getResourceAnnotationForSearchHit(state, { annotationUri: annoId, companionWindowId, windowId: 'a' }).resource['@id'],
     ).toEqual(annoId);
   });
 });
@@ -344,9 +342,7 @@ describe('getResourceAnnotationLabel', () => {
     };
 
     expect(
-      getResourceAnnotationLabel(
-        state, { annotationUri: annoId, companionWindowId, windowId: 'a' },
-      ),
+      getResourceAnnotationLabel(state, { annotationUri: annoId, companionWindowId, windowId: 'a' }),
     ).toEqual(['The Annotation Label']);
   });
 
@@ -369,9 +365,7 @@ describe('getResourceAnnotationLabel', () => {
     };
 
     expect(
-      getResourceAnnotationLabel(
-        state, { annotationUri: annoId, companionWindowId, windowId: 'a' },
-      ),
+      getResourceAnnotationLabel(state, { annotationUri: annoId, companionWindowId, windowId: 'a' }),
     ).toEqual([]);
   });
 });
diff --git a/babel.config.js b/babel.config.js
index 982d91dfd242e84dc6d63343bb19126b68f525e8..9ba7809f6a5882366601707ce147d36acf044c80 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -57,6 +57,27 @@ module.exports = function (api) {
       helpers: false, // Needed to support IE/Edge
       regenerator: true,
     },
+    [
+      '@babel/plugin-proposal-class-properties',
+      {
+        loose: true,
+      },
+    ],
+    ['@babel/plugin-proposal-private-property-in-object', { loose: true }],
+    ['@babel/plugin-proposal-private-methods', { loose: true }],
+    [
+      '@babel/plugin-proposal-object-rest-spread',
+      {
+        useBuiltIns: true,
+      },
+    ],
+    [
+      '@babel/plugin-transform-runtime',
+      {
+        corejs: false,
+        helpers: false, // Needed to support IE/Edge
+        regenerator: true,
+      },
     ],
     ['@babel/plugin-transform-regenerator', { async: false }],
     ['transform-react-remove-prop-types', { ignoreFilenames: ['node_modules'], removeImport: true }],
diff --git a/jest-puppeteer.config.js b/jest-puppeteer.config.js
index f2324320bc4f2d94696575f495b44753639c7e94..b7f3d29270c58ad2de562ff6eceb7c3ffa280179 100644
--- a/jest-puppeteer.config.js
+++ b/jest-puppeteer.config.js
@@ -4,6 +4,8 @@ module.exports = {
   },
   server: [{
     command: 'npm run server -- -p 4488',
+    host: '127.0.0.1',
+    launchTimeout: 5000,
     port: 4488,
   }],
 };
diff --git a/package.json b/package.json
index c7c58347a178b8a7426493c8f1d2ef912d82b466..8ff1ee26f87c442040da8ea15ea80a34e36b94fa 100644
--- a/package.json
+++ b/package.json
@@ -75,60 +75,59 @@
     "redux-saga": "^1.1.3",
     "redux-thunk": "^2.3.0",
     "reselect": "^4.0.0",
+    "url": "^0.11.0",
     "uuid": "^8.1.0"
   },
   "devDependencies": {
-    "@babel/cli": "^7.10.3",
-    "@babel/core": "^7.10.3",
-    "@babel/plugin-proposal-class-properties": "^7.10.1",
-    "@babel/plugin-proposal-object-rest-spread": "^7.10.3",
-    "@babel/plugin-transform-regenerator": "^7.10.3",
-    "@babel/plugin-transform-runtime": "^7.10.3",
-    "@babel/preset-env": "^7.10.3",
-    "@babel/preset-react": "^7.10.1",
-    "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-    "@typescript-eslint/eslint-plugin": "^4.21.0",
-    "@typescript-eslint/parser": "^4.21.0",
-    "babel-eslint": "^10.1.0",
-    "babel-jest": "^26.0.1",
+    "@babel/cli": "^7.17.6",
+    "@babel/core": "^7.17.7",
+    "@babel/plugin-proposal-class-properties": "^7.16.7",
+    "@babel/plugin-proposal-object-rest-spread": "^7.17.3",
+    "@babel/plugin-transform-regenerator": "^7.16.7",
+    "@babel/plugin-transform-runtime": "^7.17.0",
+    "@babel/preset-env": "^7.16.11",
+    "@babel/preset-react": "^7.16.7",
+    "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
+    "@typescript-eslint/eslint-plugin": "^5.15.0",
+    "@typescript-eslint/parser": "^5.15.0",
+    "babel-jest": "^27.5.1",
     "babel-loader": "^8.0.6",
     "babel-plugin-lodash": "^3.3.4",
     "babel-plugin-macros": "^3.0.1",
     "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
-    "bundlewatch": "^0.3.2",
+    "bundlewatch": "^0.3.3",
     "chalk": "^4.1.0",
     "codecov": "^3.7.0",
-    "core-js": "^3.4.8",
-    "enzyme": "^3.10.0",
+    "core-js": "^3.21.1",
+    "enzyme": "^3.11.0",
     "enzyme-adapter-react-16": "^1.15.0",
-    "eslint": "^7.23.0",
-    "eslint-config-airbnb": "^18.2.0",
-    "eslint-config-react-app": "^6.0.0",
-    "eslint-loader": "^4.0.2",
-    "eslint-plugin-flowtype": "^5.6.0",
-    "eslint-plugin-import": "^2.22.1",
-    "eslint-plugin-jest": "^24.0.0",
+    "eslint": "^8.11.0",
+    "eslint-config-airbnb": "^19.0.4",
+    "eslint-config-react-app": "^7.0.0",
+    "eslint-plugin-flowtype": "^8.0.3",
+    "eslint-plugin-import": "^2.25.4",
+    "eslint-plugin-jest": "^26.1.1",
     "eslint-plugin-jsx-a11y": "^6.4.1",
-    "eslint-plugin-react": "^7.23.2",
+    "eslint-plugin-react": "^7.29.4",
     "eslint-plugin-react-hooks": "^4.2.0",
     "glob": "^7.1.4",
-    "http-server": "^0.12.3",
-    "jest": "^26.0.1",
+    "http-server": "^14.1.0",
+    "jest": "^27.5.1",
     "jest-fetch-mock": "^3.0.0",
-    "jest-puppeteer": "^5.0.2",
-    "jsdom": "^16.5.3",
-    "puppeteer": "^9.0.0",
+    "jest-puppeteer": "^6.1.0",
+    "jsdom": "^19.0.0",
+    "puppeteer": "^13.5.1",
     "react": "^16.8.6",
     "react-dom": "^16.8.6",
-    "react-refresh": "^0.8.3",
+    "react-refresh": "^0.11.0",
     "redux-mock-store": "^1.5.1",
     "redux-saga-test-plan": "^4.0.0-rc.3",
-    "terser-webpack-plugin": "^4.0.0",
+    "terser-webpack-plugin": "^5.3.1",
     "unfetch": "^4.1.0",
     "url-polyfill": "^1.1.7",
-    "webpack": "^4.43.0",
-    "webpack-cli": "^4.6.0",
-    "webpack-dev-server": "^3.11.0"
+    "webpack": "^5.70.0",
+    "webpack-cli": "^4.9.2",
+    "webpack-dev-server": "^4.7.4"
   },
   "peerDependencies": {
     "react": "^16.8.3",
diff --git a/src/components/AccessTokenSender.js b/src/components/AccessTokenSender.js
index 2760b012f1a06e992e9d802da45e47c6ce2c779e..90a731d6c068e94f1de6f68fc63b6895b52d353e 100644
--- a/src/components/AccessTokenSender.js
+++ b/src/components/AccessTokenSender.js
@@ -22,7 +22,7 @@ export class AccessTokenSender extends Component {
   /** */
   render() {
     const { url } = this.props;
-    if (!url) return <></>;
+    if (!url) return null;
 
     /**
     login, clickthrough/kiosk open @id, wait for close
diff --git a/src/components/AnnotationsOverlay.js b/src/components/AnnotationsOverlay.js
index dad600e2010ff243ed1c36d5e187a9bfb1761fd9..454d407ec1562adb5d49c711cbcfbcb9e23d39f5 100644
--- a/src/components/AnnotationsOverlay.js
+++ b/src/components/AnnotationsOverlay.js
@@ -85,11 +85,10 @@ export class AnnotationsOverlay extends Component {
 
     this.initializeViewer();
 
-    const annotationsUpdated = !AnnotationsOverlay.annotationsMatch(
-      annotations, prevProps.annotations,
-    );
+    const annotationsUpdated = !AnnotationsOverlay.annotationsMatch(annotations, prevProps.annotations);
     const searchAnnotationsUpdated = !AnnotationsOverlay.annotationsMatch(
-      searchAnnotations, prevProps.searchAnnotations,
+      searchAnnotations,
+      prevProps.searchAnnotations,
     );
 
     const hoveredAnnotationsUpdated = (
@@ -389,7 +388,7 @@ export class AnnotationsOverlay extends Component {
   render() {
     const { viewer } = this.props;
 
-    if (!viewer) return <></>;
+    if (!viewer) return null;
 
     return ReactDOM.createPortal(
       (
@@ -423,7 +422,7 @@ AnnotationsOverlay.defaultProps = {
 };
 
 AnnotationsOverlay.propTypes = {
-  annotations: PropTypes.arrayOf(PropTypes.object),
+  annotations: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   canvasWorld: PropTypes.instanceOf(CanvasWorld).isRequired,
   deselectAnnotation: PropTypes.func,
   drawAnnotations: PropTypes.bool,
@@ -432,7 +431,7 @@ AnnotationsOverlay.propTypes = {
   hoverAnnotation: PropTypes.func,
   hoveredAnnotationIds: PropTypes.arrayOf(PropTypes.string),
   palette: PropTypes.object, // eslint-disable-line react/forbid-prop-types
-  searchAnnotations: PropTypes.arrayOf(PropTypes.object),
+  searchAnnotations: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   selectAnnotation: PropTypes.func,
   selectedAnnotationId: PropTypes.string,
   viewer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
diff --git a/src/components/AudioViewer.js b/src/components/AudioViewer.js
index fd63c4053fb73af660192e406cc6ac3b7f71db38..15d51c3d1401e1e63576fbf9e29c9cc86e362e69 100644
--- a/src/components/AudioViewer.js
+++ b/src/components/AudioViewer.js
@@ -32,8 +32,8 @@ export class AudioViewer extends Component {
 
 AudioViewer.propTypes = {
   audioOptions: PropTypes.object, // eslint-disable-line react/forbid-prop-types
-  audioResources: PropTypes.arrayOf(PropTypes.object),
-  captions: PropTypes.arrayOf(PropTypes.object),
+  audioResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
+  captions: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   classes: PropTypes.objectOf(PropTypes.string).isRequired,
 };
 
diff --git a/src/components/CanvasAnnotations.js b/src/components/CanvasAnnotations.js
index 2a7efadb5f40abefeceecbe00d4874843c76c88a..6b8e4e87b9cf3ba60c00a237aa53a43a4a35db9d 100644
--- a/src/components/CanvasAnnotations.js
+++ b/src/components/CanvasAnnotations.js
@@ -62,7 +62,7 @@ export class CanvasAnnotations extends Component {
       listContainerComponent, htmlSanitizationRuleSet, hoveredAnnotationIds,
       containerRef,
     } = this.props;
-    if (annotations.length === 0) return <></>;
+    if (annotations.length === 0) return null;
 
     return (
       <>
diff --git a/src/components/ChangeThemeDialog.js b/src/components/ChangeThemeDialog.js
index 37e04ca6629d504d44b870724cad9c1739b0c0ac..e5bb6102de498e007c2feb6130c8f645d3efbe8e 100644
--- a/src/components/ChangeThemeDialog.js
+++ b/src/components/ChangeThemeDialog.js
@@ -32,7 +32,6 @@ export class ChangeThemeDialog extends Component {
   */
   constructor(props) {
     super(props);
-    this.selectedItemRef = React.createRef();
     this.handleThemeChange = this.handleThemeChange.bind(this);
   }
 
diff --git a/src/components/CollectionDialog.js b/src/components/CollectionDialog.js
index 53e65d67117f2b899e2535677041313eb3fedd71..df4d9012427cfeb2757ff7b6bd375731d2b6986f 100644
--- a/src/components/CollectionDialog.js
+++ b/src/components/CollectionDialog.js
@@ -155,7 +155,7 @@ export class CollectionDialog extends Component {
     // to maybe pass a ref.
     if (!this.dialogContainer()) {
       this.forceUpdate();
-      return <></>;
+      return null;
     }
     if (!ready) return this.placeholder();
 
diff --git a/src/components/CompanionWindowFactory.js b/src/components/CompanionWindowFactory.js
index 909e73944ed823600330f2338fa7fc897f9a5a3b..7bc32d1e84afd2838eefe8575e48f90eae1cdb77 100644
--- a/src/components/CompanionWindowFactory.js
+++ b/src/components/CompanionWindowFactory.js
@@ -60,7 +60,7 @@ export class CompanionWindowFactory extends Component {
 
     const type = CompanionWindowRegistry[content];
 
-    if (!type) return <></>;
+    if (!type) return null;
 
     return React.createElement(type, { id, windowId });
   }
diff --git a/src/components/GalleryViewThumbnail.js b/src/components/GalleryViewThumbnail.js
index 80ae8e1277e13296f66c1a2e379b00c736000ef3..acfe18aae196a5a4e489918c88a556042274800f 100644
--- a/src/components/GalleryViewThumbnail.js
+++ b/src/components/GalleryViewThumbnail.js
@@ -19,6 +19,7 @@ export class GalleryViewThumbnail extends Component {
   constructor(props) {
     super(props);
 
+    this.myRef = React.createRef();
     this.state = { requestedAnnotations: false };
 
     this.handleSelect = this.handleSelect.bind(this);
@@ -26,6 +27,14 @@ export class GalleryViewThumbnail extends Component {
     this.handleIntersection = this.handleIntersection.bind(this);
   }
 
+  // eslint-disable-next-line require-jsdoc
+  componentDidMount() {
+    const { selected } = this.props;
+    if (selected) {
+      this.myRef.current?.scrollIntoView(true);
+    }
+  }
+
   /** @private */
   handleSelect() {
     const {
@@ -112,6 +121,7 @@ export class GalleryViewThumbnail extends Component {
           }
           onClick={this.handleSelect}
           onKeyUp={this.handleKey}
+          ref={this.myRef}
           role="button"
           tabIndex={0}
         >
diff --git a/src/components/LabelValueMetadata.js b/src/components/LabelValueMetadata.js
index a003b9a4007fece59dd7c349cf6222c014485f80..91fd0b40d3305341d53c50f0bbc58ab67913db2d 100644
--- a/src/components/LabelValueMetadata.js
+++ b/src/components/LabelValueMetadata.js
@@ -17,7 +17,7 @@ export class LabelValueMetadata extends Component {
     const { defaultLabel, labelValuePairs } = this.props;
 
     if (labelValuePairs.length === 0) {
-      return (<></>);
+      return null;
     }
 
     /* eslint-disable react/no-array-index-key */
diff --git a/src/components/LocalePicker.js b/src/components/LocalePicker.js
index e2177036c9f25700a5b8f7d7fc969b22814758aa..24cf3bd7b8d864036f46323de5d2ff34eda7ccc2 100644
--- a/src/components/LocalePicker.js
+++ b/src/components/LocalePicker.js
@@ -21,7 +21,7 @@ export class LocalePicker extends Component {
       setLocale,
     } = this.props;
 
-    if (!setLocale || availableLocales.length < 2) return <></>;
+    if (!setLocale || availableLocales.length < 2) return null;
     return (
       <FormControl>
         <Select
diff --git a/src/components/ManifestRelatedLinks.js b/src/components/ManifestRelatedLinks.js
index 533b4522d1b408ff62173ee5e964893651b50841..943d287ff3be7df3682ce5224777412ca4e2062f 100644
--- a/src/components/ManifestRelatedLinks.js
+++ b/src/components/ManifestRelatedLinks.js
@@ -20,6 +20,7 @@ export class ManifestRelatedLinks extends Component {
       classes,
       homepage,
       manifestUrl,
+      related,
       renderings,
       seeAlso,
       id,
@@ -68,17 +69,34 @@ export class ManifestRelatedLinks extends Component {
               }
             </>
           )}
+          { related && (
+            <>
+              <Typography variant="subtitle2" component="dt">{t('iiif_related')}</Typography>
+              {
+                related.map(relatedItem => (
+                  <Typography key={relatedItem.value} variant="body1" component="dd">
+                    <Link target="_blank" rel="noopener noreferrer" href={relatedItem.value}>
+                      {relatedItem.label || relatedItem.value}
+                    </Link>
+                    { relatedItem.format && (
+                      <Typography component="span">{` (${relatedItem.format})`}</Typography>
+                    )}
+                  </Typography>
+                ))
+              }
+            </>
+          )}
           { seeAlso && (
             <>
               <Typography variant="subtitle2" component="dt">{t('iiif_seeAlso')}</Typography>
               {
-                seeAlso.map(related => (
-                  <Typography key={related.value} variant="body1" component="dd">
-                    <Link target="_blank" rel="noopener noreferrer" href={related.value}>
-                      {related.label || related.value}
+                seeAlso.map(seeAlsoItem => (
+                  <Typography key={seeAlsoItem.value} variant="body1" component="dd">
+                    <Link target="_blank" rel="noopener noreferrer" href={seeAlsoItem.value}>
+                      {seeAlsoItem.label || seeAlsoItem.value}
                     </Link>
-                    { related.format && (
-                      <Typography component="span">{` (${related.format})`}</Typography>
+                    { seeAlsoItem.format && (
+                      <Typography component="span">{` (${seeAlsoItem.format})`}</Typography>
                     )}
                   </Typography>
                 ))
@@ -110,6 +128,11 @@ ManifestRelatedLinks.propTypes = {
   })),
   id: PropTypes.string.isRequired,
   manifestUrl: PropTypes.string,
+  related: PropTypes.arrayOf(PropTypes.shape({
+    format: PropTypes.string,
+    label: PropTypes.string,
+    value: PropTypes.string,
+  })),
   renderings: PropTypes.arrayOf(PropTypes.shape({
     label: PropTypes.string,
     value: PropTypes.string,
@@ -125,6 +148,7 @@ ManifestRelatedLinks.propTypes = {
 ManifestRelatedLinks.defaultProps = {
   homepage: null,
   manifestUrl: null,
+  related: null,
   renderings: null,
   seeAlso: null,
   t: key => key,
diff --git a/src/components/OpenSeadragonViewer.js b/src/components/OpenSeadragonViewer.js
index 7c3244bf072bfc8ebead0d147e3939a5f8ec3ab3..3f352eddd9139a964ae92f2b4d9f2e6b6d079696 100644
--- a/src/components/OpenSeadragonViewer.js
+++ b/src/components/OpenSeadragonViewer.js
@@ -167,10 +167,11 @@ export class OpenSeadragonViewer extends Component {
   /** */
   addAllImageSources(zoomAfterAdd = true) {
     const { nonTiledImages, infoResponses } = this.props;
-    Promise.all(
-      infoResponses.map(infoResponse => this.addTileSource(infoResponse)),
-      nonTiledImages.map(image => this.addNonTiledImage(image)),
-    ).then(() => {
+
+    return Promise.allSettled([
+      ...infoResponses.map(infoResponse => this.addTileSource(infoResponse)),
+      ...nonTiledImages.map(image => this.addNonTiledImage(image)),
+    ]).then(() => {
       if (infoResponses[0] || nonTiledImages[0]) {
         if (zoomAfterAdd) this.zoomToWorld();
         this.refreshTileProperties();
@@ -193,7 +194,7 @@ export class OpenSeadragonViewer extends Component {
         reject();
       }
 
-      viewer.addSimpleImage({
+      resolve(viewer.addSimpleImage({
         error: event => reject(event),
         fitBounds: new OpenSeadragon.Rect(
           ...canvasWorld.contentResourceToWorldCoordinates(contentResource),
@@ -202,7 +203,7 @@ export class OpenSeadragonViewer extends Component {
         opacity: canvasWorld.layerOpacityOfImageResource(contentResource),
         success: event => resolve(event),
         url: contentResource.id,
-      });
+      }));
     });
   }
 
@@ -238,7 +239,11 @@ export class OpenSeadragonViewer extends Component {
   /** */
   refreshTileProperties() {
     const { canvasWorld } = this.props;
-    const { viewer: { world } } = this.state;
+    const { viewer } = this.state;
+
+    if (!viewer) return;
+
+    const { world } = viewer;
 
     const items = [];
     for (let i = 0; i < world.getItemCount(); i += 1) {
@@ -259,7 +264,7 @@ export class OpenSeadragonViewer extends Component {
   fitBounds(x, y, w, h, immediately = true) {
     const { viewer } = this.state;
 
-    viewer.viewport.fitBounds(
+    viewer && viewer.viewport && viewer.viewport.fitBounds(
       new OpenSeadragon.Rect(x, y, w, h),
       immediately,
     );
@@ -350,20 +355,18 @@ export class OpenSeadragonViewer extends Component {
     ));
 
     return (
-      <>
-        <section
-          className={classNames(ns('osd-container'), classes.osdContainer)}
-          id={`${windowId}-osd`}
-          ref={this.ref}
-          aria-label={t('item', { label })}
-          aria-live="polite"
-        >
-          { drawAnnotations
+      <section
+        className={classNames(ns('osd-container'), classes.osdContainer)}
+        id={`${windowId}-osd`}
+        ref={this.ref}
+        aria-label={t('item', { label })}
+        aria-live="polite"
+      >
+        { drawAnnotations
             && <AnnotationsOverlay viewer={viewer} windowId={windowId} /> }
-          { enhancedChildren }
-          <PluginHook viewer={viewer} {...{ ...this.props, children: null }} />
-        </section>
-      </>
+        { enhancedChildren }
+        <PluginHook viewer={viewer} {...{ ...this.props, children: null }} />
+      </section>
     );
   }
 }
@@ -383,7 +386,7 @@ OpenSeadragonViewer.propTypes = {
   children: PropTypes.node,
   classes: PropTypes.objectOf(PropTypes.string).isRequired,
   drawAnnotations: PropTypes.bool,
-  infoResponses: PropTypes.arrayOf(PropTypes.object),
+  infoResponses: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   label: PropTypes.string,
   nonTiledImages: PropTypes.array, // eslint-disable-line react/forbid-prop-types
   osdConfig: PropTypes.object, // eslint-disable-line react/forbid-prop-types
diff --git a/src/components/PrimaryWindow.js b/src/components/PrimaryWindow.js
index ee5d4e672c07edeaade90ed46aecd6058bca9661..05cecad9b6325d91cba30d8db6d7e54c5f22d0c0 100644
--- a/src/components/PrimaryWindow.js
+++ b/src/components/PrimaryWindow.js
@@ -33,11 +33,9 @@ export class PrimaryWindow extends Component {
     } = this.props;
     if (isCollection) {
       return (
-        <>
-          <SelectCollection
-            windowId={windowId}
-          />
-        </>
+        <SelectCollection
+          windowId={windowId}
+        />
       );
     }
     if (isFetching === false) {
@@ -92,13 +90,13 @@ export class PrimaryWindow extends Component {
 }
 
 PrimaryWindow.propTypes = {
-  audioResources: PropTypes.arrayOf(PropTypes.object),
+  audioResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   children: PropTypes.node,
   classes: PropTypes.objectOf(PropTypes.string).isRequired,
   isCollection: PropTypes.bool,
   isCollectionDialogVisible: PropTypes.bool,
   isFetching: PropTypes.bool,
-  videoResources: PropTypes.arrayOf(PropTypes.object),
+  videoResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   view: PropTypes.string,
   windowId: PropTypes.string.isRequired,
 };
diff --git a/src/components/SearchPanelNavigation.js b/src/components/SearchPanelNavigation.js
index 59f9eb65b79d41e5e935791658c0ab5ab0106fcc..21c76dda1e794a2db646fa8f58a6ef23a57b28ee 100644
--- a/src/components/SearchPanelNavigation.js
+++ b/src/components/SearchPanelNavigation.js
@@ -53,30 +53,29 @@ export class SearchPanelNavigation extends Component {
     if (searchHits.length < numTotal) {
       lengthText += '+';
     }
+
+    if (searchHits.length === 0) return null;
+
     return (
-      <>
-        {(searchHits.length > 0) && (
-          <Typography variant="body2" align="center" classes={classes}>
-            <MiradorMenuButton
-              aria-label={t('searchPreviousResult')}
-              disabled={!this.hasPreviousResult(currentHitIndex)}
-              onClick={() => this.previousSearchResult(currentHitIndex)}
-            >
-              <ChevronLeftIcon style={iconStyle} />
-            </MiradorMenuButton>
-            <span style={{ unicodeBidi: 'plaintext' }}>
-              {t('pagination', { current: currentHitIndex + 1, total: lengthText })}
-            </span>
-            <MiradorMenuButton
-              aria-label={t('searchNextResult')}
-              disabled={!this.hasNextResult(currentHitIndex)}
-              onClick={() => this.nextSearchResult(currentHitIndex)}
-            >
-              <ChevronRightIcon style={iconStyle} />
-            </MiradorMenuButton>
-          </Typography>
-        )}
-      </>
+      <Typography variant="body2" align="center" classes={classes}>
+        <MiradorMenuButton
+          aria-label={t('searchPreviousResult')}
+          disabled={!this.hasPreviousResult(currentHitIndex)}
+          onClick={() => this.previousSearchResult(currentHitIndex)}
+        >
+          <ChevronLeftIcon style={iconStyle} />
+        </MiradorMenuButton>
+        <span style={{ unicodeBidi: 'plaintext' }}>
+          {t('pagination', { current: currentHitIndex + 1, total: lengthText })}
+        </span>
+        <MiradorMenuButton
+          aria-label={t('searchNextResult')}
+          disabled={!this.hasNextResult(currentHitIndex)}
+          onClick={() => this.nextSearchResult(currentHitIndex)}
+        >
+          <ChevronRightIcon style={iconStyle} />
+        </MiradorMenuButton>
+      </Typography>
     );
   }
 }
@@ -84,7 +83,7 @@ SearchPanelNavigation.propTypes = {
   classes: PropTypes.objectOf(PropTypes.string),
   direction: PropTypes.string.isRequired,
   numTotal: PropTypes.number,
-  searchHits: PropTypes.arrayOf(PropTypes.object),
+  searchHits: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   searchService: PropTypes.shape({
     id: PropTypes.string,
   }).isRequired,
diff --git a/src/components/SearchResults.js b/src/components/SearchResults.js
index 7c5ca415b3d3d993451d6b3c8c0044358c5f7c4f..bb75972a80306f2dc0470dfbe0ba7cca10a5405e 100644
--- a/src/components/SearchResults.js
+++ b/src/components/SearchResults.js
@@ -149,8 +149,8 @@ SearchResults.propTypes = {
   isFetching: PropTypes.bool,
   nextSearch: PropTypes.string,
   query: PropTypes.string,
-  searchAnnotations: PropTypes.arrayOf(PropTypes.object),
-  searchHits: PropTypes.arrayOf(PropTypes.object),
+  searchAnnotations: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
+  searchHits: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   searchNumTotal: PropTypes.number,
   t: PropTypes.func,
   windowId: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types
diff --git a/src/components/SidebarIndexItem.js b/src/components/SidebarIndexItem.js
index 909b218a95b8fe3d3604ddb4d7105b89f4fbfe2b..b880e180a3b41a7f7ae9013c2f8731ce2a2264af 100644
--- a/src/components/SidebarIndexItem.js
+++ b/src/components/SidebarIndexItem.js
@@ -12,14 +12,12 @@ export class SidebarIndexItem extends Component {
     } = this.props;
 
     return (
-      <>
-        <Typography
-          className={classNames(classes.label)}
-          variant="body1"
-        >
-          {label}
-        </Typography>
-      </>
+      <Typography
+        className={classNames(classes.label)}
+        variant="body1"
+      >
+        {label}
+      </Typography>
     );
   }
 }
diff --git a/src/components/SidebarIndexTableOfContents.js b/src/components/SidebarIndexTableOfContents.js
index bea3b9c9d7841984402499ace1c848e58ae0cdf0..badc3e77fb57c5cdb6da04aed9726e9baf182ede 100644
--- a/src/components/SidebarIndexTableOfContents.js
+++ b/src/components/SidebarIndexTableOfContents.js
@@ -113,21 +113,19 @@ export class SidebarIndexTableOfContents extends Component {
     } = this.props;
 
     if (!treeStructure) {
-      return <></>;
+      return null;
     }
 
     return (
-      <>
-        <TreeView
-          className={classes.root}
-          defaultCollapseIcon={<ExpandMoreIcon color="action" />}
-          defaultExpandIcon={<ChevronRightIcon color="action" />}
-          defaultEndIcon={<></>}
-          expanded={expandedNodeIds}
-        >
-          {this.buildTreeItems(treeStructure.nodes, visibleNodeIds, containerRef, nodeIdToScrollTo)}
-        </TreeView>
-      </>
+      <TreeView
+        className={classes.root}
+        defaultCollapseIcon={<ExpandMoreIcon color="action" />}
+        defaultExpandIcon={<ChevronRightIcon color="action" />}
+        defaultEndIcon={null}
+        expanded={expandedNodeIds}
+      >
+        {this.buildTreeItems(treeStructure.nodes, visibleNodeIds, containerRef, nodeIdToScrollTo)}
+      </TreeView>
     );
   }
 }
diff --git a/src/components/ThumbnailNavigation.js b/src/components/ThumbnailNavigation.js
index fb85a483815b11b45d68ddccb007814fd9f54915..e7c6db2945535d7a293694054acb970da4307316 100644
--- a/src/components/ThumbnailNavigation.js
+++ b/src/components/ThumbnailNavigation.js
@@ -69,7 +69,9 @@ export class ThumbnailNavigation extends Component {
    */
   calculateScaledSize(index) {
     const { thumbnailNavigation, canvasGroupings, position } = this.props;
-    const canvases = canvasGroupings[index] || [];
+    const canvases = canvasGroupings[index];
+    if (!canvases) return thumbnailNavigation.width + this.spacing;
+
     const world = new CanvasWorld(canvases);
     const bounds = world.worldBounds();
     switch (position) {
@@ -181,7 +183,7 @@ export class ThumbnailNavigation extends Component {
       windowId,
     } = this.props;
     if (position === 'off') {
-      return <></>;
+      return null;
     }
     const htmlDir = viewingDirection === 'right-to-left' ? 'rtl' : 'ltr';
     const itemData = {
diff --git a/src/components/VideoViewer.js b/src/components/VideoViewer.js
index 6dd39e8be039efda095846e22dc77d4dd44b9125..a7eae5ba6500a564dcbf2ebc208a4be3c81e7937 100644
--- a/src/components/VideoViewer.js
+++ b/src/components/VideoViewer.js
@@ -183,6 +183,7 @@ export class VideoViewer extends Component {
 VideoViewer.propTypes = {
   annotations: PropTypes.arrayOf(PropTypes.object),
   canvas: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+  captions: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   classes: PropTypes.objectOf(PropTypes.string).isRequired,
   currentTime: PropTypes.number,
   muted: PropTypes.bool,
@@ -192,6 +193,7 @@ VideoViewer.propTypes = {
   setPaused: PropTypes.func,
   textTrackDisabled: PropTypes.bool,
   videoOptions: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+  videoResources: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   windowId: PropTypes.string.isRequired,
 };
 
diff --git a/src/components/Window.js b/src/components/Window.js
index 56d3bd45e6c893b89638f30836c3aaccf1c771ed..30462d2e7ed84326a6f4687e16cb9043c0e9abf2 100644
--- a/src/components/Window.js
+++ b/src/components/Window.js
@@ -83,8 +83,11 @@ export class Window extends Component {
         elevation={1}
         id={windowId}
         className={
-          cn(classes.window, ns('window'),
-            maximized ? classes.maximized : null)
+          cn(
+            classes.window,
+            ns('window'),
+            maximized ? classes.maximized : null,
+          )
 }
         aria-label={t('window', { label })}
       >
diff --git a/src/components/WindowSideBar.js b/src/components/WindowSideBar.js
index 5381a60a305cb2c6cede7eb707c924bb55035477..eaeceaae43ea026917d68940037e207b4070d639 100644
--- a/src/components/WindowSideBar.js
+++ b/src/components/WindowSideBar.js
@@ -18,23 +18,21 @@ export class WindowSideBar extends Component {
     } = this.props;
 
     return (
-      <>
-        <Drawer
-          variant="persistent"
-          className={classNames(classes.drawer)}
-          classes={{ paper: classNames(classes.paper) }}
-          anchor={direction === 'rtl' ? 'right' : 'left'}
-          PaperProps={{
-            'aria-label': t('sidebarPanelsNavigation'),
-            component: 'nav',
-            style: { height: '100%', position: 'relative' },
-          }}
-          SlideProps={{ direction: direction === 'rtl' ? 'left' : 'right', mountOnEnter: true, unmountOnExit: true }}
-          open={sideBarOpen}
-        >
-          <WindowSideBarButtons windowId={windowId} />
-        </Drawer>
-      </>
+      <Drawer
+        variant="persistent"
+        className={classNames(classes.drawer)}
+        classes={{ paper: classNames(classes.paper) }}
+        anchor={direction === 'rtl' ? 'right' : 'left'}
+        PaperProps={{
+          'aria-label': t('sidebarPanelsNavigation'),
+          component: 'nav',
+          style: { height: '100%', position: 'relative' },
+        }}
+        SlideProps={{ direction: direction === 'rtl' ? 'left' : 'right', mountOnEnter: true, unmountOnExit: true }}
+        open={sideBarOpen}
+      >
+        <WindowSideBarButtons windowId={windowId} />
+      </Drawer>
     );
   }
 }
diff --git a/src/components/WindowSideBarButtons.js b/src/components/WindowSideBarButtons.js
index b5645522c2dd8ea863b44ebb1d907c29979bdf57..fd4c717f3f8c1762c2312d14137ef48969e6d07f 100644
--- a/src/components/WindowSideBarButtons.js
+++ b/src/components/WindowSideBarButtons.js
@@ -10,6 +10,28 @@ import AttributionIcon from '@material-ui/icons/CopyrightSharp';
 import LayersIcon from '@material-ui/icons/LayersSharp';
 import SearchIcon from '@material-ui/icons/SearchSharp';
 import CanvasIndexIcon from './icons/CanvasIndexIcon';
+
+/** */
+function TabButton({ t, value, ...tabProps }) {
+  return (
+    <Tooltip title={t('openCompanionWindow', { context: value })}>
+      <Tab
+        {...tabProps}
+        value={value}
+        aria-label={
+          t('openCompanionWindow', { context: value })
+        }
+        disableRipple
+      />
+    </Tooltip>
+  );
+}
+
+TabButton.propTypes = {
+  t: PropTypes.func.isRequired,
+  value: PropTypes.string.isRequired,
+};
+
 /**
  *
  */
@@ -50,21 +72,6 @@ export class WindowSideBarButtons extends Component {
       t,
     } = this.props;
 
-    /** */
-    const TabButton = props => (
-      <Tooltip title={t('openCompanionWindow', { context: props.value })}>
-        <Tab
-          {...props}
-          classes={{ root: classes.tab, selected: classes.tabSelected }}
-          aria-label={
-            t('openCompanionWindow', { context: props.value })
-          }
-          disableRipple
-          onKeyUp={this.handleKeyUp}
-        />
-      </Tooltip>
-    );
-
     return (
       <Tabs
         classes={{ flexContainer: classes.tabsFlexContainer, indicator: classes.tabsIndicator }}
@@ -80,24 +87,36 @@ export class WindowSideBarButtons extends Component {
         { panels.info && (
           <TabButton
             value="info"
+            onKeyUp={this.handleKeyUp}
+            classes={{ root: classes.tab, selected: classes.tabSelected }}
+            t={t}
             icon={(<InfoIcon />)}
           />
         )}
         { panels.attribution && (
           <TabButton
             value="attribution"
+            onKeyUp={this.handleKeyUp}
+            classes={{ root: classes.tab, selected: classes.tabSelected }}
+            t={t}
             icon={(<AttributionIcon />)}
           />
         )}
         { panels.canvas && (
           <TabButton
             value="canvas"
+            onKeyUp={this.handleKeyUp}
+            classes={{ root: classes.tab, selected: classes.tabSelected }}
+            t={t}
             icon={(<CanvasIndexIcon />)}
           />
         )}
         {panels.annotations && (hasAnnotations || hasAnyAnnotations) && (
           <TabButton
             value="annotations"
+            onKeyUp={this.handleKeyUp}
+            classes={{ root: classes.tab, selected: classes.tabSelected }}
+            t={t}
             icon={(
               <Badge classes={{ badge: classes.badge }} invisible={!hasAnnotations} variant="dot">
                 <AnnotationIcon />
@@ -108,6 +127,9 @@ export class WindowSideBarButtons extends Component {
         {panels.search && hasSearchService && (
           <TabButton
             value="search"
+            onKeyUp={this.handleKeyUp}
+            classes={{ root: classes.tab, selected: classes.tabSelected }}
+            t={t}
             icon={(
               <Badge classes={{ badge: classes.badge }} invisible={!hasSearchResults} variant="dot">
                 <SearchIcon />
@@ -118,6 +140,9 @@ export class WindowSideBarButtons extends Component {
         { panels.layers && hasAnyLayers && (
           <TabButton
             value="layers"
+            onKeyUp={this.handleKeyUp}
+            classes={{ root: classes.tab, selected: classes.tabSelected }}
+            t={t}
             icon={(
               <Badge classes={{ badge: classes.badge }} invisible={!hasCurrentLayers} variant="dot">
                 <LayersIcon />
@@ -128,6 +153,9 @@ export class WindowSideBarButtons extends Component {
         { PluginComponents
           && PluginComponents.map(PluginComponent => (
             <TabButton
+              onKeyUp={this.handleKeyUp}
+              classes={{ root: classes.tab, selected: classes.tabSelected }}
+              t={t}
               key={PluginComponent.value}
               value={PluginComponent.value}
               icon={<PluginComponent />}
diff --git a/src/components/WindowSideBarCanvasPanel.js b/src/components/WindowSideBarCanvasPanel.js
index abd76e08f454494c84c583d824e724ff22cf1080..9d4e988f7958801e75972ce91316c9807a328986 100644
--- a/src/components/WindowSideBarCanvasPanel.js
+++ b/src/components/WindowSideBarCanvasPanel.js
@@ -162,7 +162,7 @@ WindowSideBarCanvasPanel.propTypes = {
   collection: PropTypes.object, // eslint-disable-line react/forbid-prop-types
   id: PropTypes.string.isRequired,
   sequenceId: PropTypes.string,
-  sequences: PropTypes.arrayOf(PropTypes.object),
+  sequences: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
   showMultipart: PropTypes.func.isRequired,
   showToc: PropTypes.bool,
   t: PropTypes.func.isRequired,
diff --git a/src/components/WindowSideBarCollectionPanel.js b/src/components/WindowSideBarCollectionPanel.js
index f480ec42ca2bda9e07da7cf56283f0745e57a9e4..cf58cccfff8665ac17d55ffeb400e1e84e9d2c45 100644
--- a/src/components/WindowSideBarCollectionPanel.js
+++ b/src/components/WindowSideBarCollectionPanel.js
@@ -12,6 +12,40 @@ import ArrowUpwardIcon from '@material-ui/icons/ArrowUpwardSharp';
 import CompanionWindow from '../containers/CompanionWindow';
 import IIIFThumbnail from '../containers/IIIFThumbnail';
 
+/** */
+function Item({
+  manifest, canvasNavigation, variant, ...otherProps
+}) {
+  return (
+    <MenuItem
+      alignItems="flex-start"
+      button
+      component="li"
+      {...otherProps}
+    >
+      { variant === 'thumbnail' && (
+        <ListItemIcon>
+          <IIIFThumbnail
+            resource={manifest}
+            maxHeight={canvasNavigation.height}
+            maxWidth={canvasNavigation.width}
+          />
+        </ListItemIcon>
+      )}
+      <ListItemText>{WindowSideBarCollectionPanel.getUseableLabel(manifest)}</ListItemText>
+    </MenuItem>
+  );
+}
+
+Item.propTypes = {
+  canvasNavigation: PropTypes.shape({
+    height: PropTypes.number,
+    width: PropTypes.number,
+  }).isRequired,
+  manifest: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+  variant: PropTypes.string.isRequired,
+};
+
 /** */
 export class WindowSideBarCollectionPanel extends Component {
   /** */
@@ -54,29 +88,6 @@ export class WindowSideBarCollectionPanel extends Component {
       windowId,
     } = this.props;
 
-    /** */
-    const Item = ({ manifest, ...otherProps }) => (
-      <MenuItem
-        className={classes.menuItem}
-        alignItems="flex-start"
-        button
-        component="li"
-        selected={manifestId === manifest.id}
-        {...otherProps}
-      >
-        { variant === 'thumbnail' && (
-          <ListItemIcon>
-            <IIIFThumbnail
-              resource={manifest}
-              maxHeight={canvasNavigation.height}
-              maxWidth={canvasNavigation.width}
-            />
-          </ListItemIcon>
-        )}
-        <ListItemText>{WindowSideBarCollectionPanel.getUseableLabel(manifest)}</ListItemText>
-      </MenuItem>
-    );
-
     return (
       <CompanionWindow
         title={t(this.isMultipart() ? 'multipartCollection' : 'collection')}
@@ -127,7 +138,15 @@ export class WindowSideBarCollectionPanel extends Component {
               };
 
               return (
-                <Item key={manifest.id} onClick={onClick} manifest={manifest} />
+                <Item
+                  key={manifest.id}
+                  onClick={onClick}
+                  canvasNavigation={canvasNavigation}
+                  manifest={manifest}
+                  variant={variant}
+                  className={classes.menuItem}
+                  selected={manifestId === manifest.id}
+                />
               );
             })
           }
@@ -142,7 +161,15 @@ export class WindowSideBarCollectionPanel extends Component {
               };
 
               return (
-                <Item key={manifest.id} onClick={onClick} manifest={manifest} />
+                <Item
+                  key={manifest.id}
+                  onClick={onClick}
+                  canvasNavigation={canvasNavigation}
+                  manifest={manifest}
+                  variant={variant}
+                  className={classes.menuItem}
+                  selected={manifestId === manifest.id}
+                />
               );
             })
           }
@@ -159,14 +186,11 @@ WindowSideBarCollectionPanel.propTypes = {
   }).isRequired,
   classes: PropTypes.objectOf(PropTypes.string).isRequired,
   collection: PropTypes.object, // eslint-disable-line react/forbid-prop-types
-  collectionId: PropTypes.string.isRequired,
   collectionPath: PropTypes.arrayOf(PropTypes.string),
-  error: PropTypes.string,
   id: PropTypes.string.isRequired,
   isFetching: PropTypes.bool,
   manifestId: PropTypes.string.isRequired,
   parentCollection: PropTypes.object, // eslint-disable-line react/forbid-prop-types
-  ready: PropTypes.bool,
   t: PropTypes.func,
   updateCompanionWindow: PropTypes.func.isRequired,
   updateWindow: PropTypes.func.isRequired,
@@ -177,10 +201,8 @@ WindowSideBarCollectionPanel.propTypes = {
 WindowSideBarCollectionPanel.defaultProps = {
   collection: null,
   collectionPath: [],
-  error: null,
   isFetching: false,
   parentCollection: null,
-  ready: false,
   t: k => k,
   variant: null,
 };
diff --git a/src/components/WindowTopBarPluginArea.js b/src/components/WindowTopBarPluginArea.js
index ce5ec626f4317974bf6fd0dbf143fe6c41c219f0..63e32ac03e89f14dcb7895c4e7167c3ba5d4d8ad 100644
--- a/src/components/WindowTopBarPluginArea.js
+++ b/src/components/WindowTopBarPluginArea.js
@@ -8,9 +8,7 @@ export class WindowTopBarPluginArea extends Component {
   /** */
   render() {
     return (
-      <>
-        <PluginHook {...this.props} />
-      </>
+      <PluginHook {...this.props} />
     );
   }
 }
diff --git a/src/components/WindowTopBarPluginMenu.js b/src/components/WindowTopBarPluginMenu.js
index b3e6e4b3c00aa04a6caf668076311e70619e6181..f19331c035f682de00e35ff64a8b22c83bd959f8 100644
--- a/src/components/WindowTopBarPluginMenu.js
+++ b/src/components/WindowTopBarPluginMenu.js
@@ -49,7 +49,7 @@ export class WindowTopBarPluginMenu extends Component {
     } = this.props;
     const { anchorEl } = this.state;
 
-    if (!PluginComponents || PluginComponents.length === 0) return (<></>);
+    if (!PluginComponents || PluginComponents.length === 0) return null;
 
     return (
       <>
diff --git a/src/components/WindowTopBarTitle.js b/src/components/WindowTopBarTitle.js
index ed36e8aff8847d47fe33e70b65f489d6a7bb01e2..bc2c466825e0ac0fd1d2d46258f6285fe20eb815 100644
--- a/src/components/WindowTopBarTitle.js
+++ b/src/components/WindowTopBarTitle.js
@@ -4,6 +4,19 @@ import Typography from '@material-ui/core/Typography';
 import Skeleton from '@material-ui/lab/Skeleton';
 import ErrorIcon from '@material-ui/icons/ErrorOutlineSharp';
 
+/** */
+function TitleTypography({ children, ...props }) {
+  return (
+    <Typography variant="h2" noWrap color="inherit" {...props}>
+      {children}
+    </Typography>
+  );
+}
+
+TitleTypography.propTypes = {
+  children: PropTypes.node.isRequired,
+};
+
 /**
  * WindowTopBarTitle
  */
@@ -17,17 +30,10 @@ export class WindowTopBarTitle extends Component {
       classes, error, hideWindowTitle, isFetching, manifestTitle,
     } = this.props;
 
-    /** */
-    const TitleTypography = props => (
-      <Typography variant="h2" noWrap color="inherit" className={classes.title} {...props}>
-        {props.children}
-      </Typography>
-    );
-
     let title = null;
     if (isFetching) {
       title = (
-        <TitleTypography>
+        <TitleTypography className={classes.title}>
           <Skeleton variant="text" />
         </TitleTypography>
       );
@@ -35,7 +41,7 @@ export class WindowTopBarTitle extends Component {
       title = (
         <>
           <ErrorIcon color="error" />
-          <TitleTypography color="textSecondary">
+          <TitleTypography color="textSecondary" className={classes.title}>
             {error}
           </TitleTypography>
         </>
@@ -44,7 +50,7 @@ export class WindowTopBarTitle extends Component {
       title = (<div className={classes.title} />);
     } else {
       title = (
-        <TitleTypography>
+        <TitleTypography className={classes.title}>
           {manifestTitle}
         </TitleTypography>
       );
diff --git a/src/components/WindowViewer.js b/src/components/WindowViewer.js
index 667b99bff1fd32d65a8081972e3ab1a21283ece0..91257b42a43f02891218dcce4cb7b68c89c001ff 100644
--- a/src/components/WindowViewer.js
+++ b/src/components/WindowViewer.js
@@ -29,9 +29,7 @@ export class WindowViewer extends Component {
 
     const { hasError } = this.state;
 
-    if (hasError) {
-      return <></>;
-    }
+    if (hasError) return null;
 
     return (
       <Suspense fallback={<div />}>
diff --git a/src/components/WorkspaceAdd.js b/src/components/WorkspaceAdd.js
index 696038d3b07fec4144f26e03cce0f38bc8333cb3..f1676e47a9ebe1116ae7dbf23cce29538ba3b7d2 100644
--- a/src/components/WorkspaceAdd.js
+++ b/src/components/WorkspaceAdd.js
@@ -179,7 +179,10 @@ export class WorkspaceAdd extends React.Component {
 
 WorkspaceAdd.propTypes = {
   addResource: PropTypes.func,
-  catalog: PropTypes.arrayOf(PropTypes.object),
+  catalog: PropTypes.arrayOf(PropTypes.shape({
+    manifestId: PropTypes.string.isRequired,
+    provider: PropTypes.string,
+  })),
   classes: PropTypes.objectOf(PropTypes.string),
   setWorkspaceAddVisibility: PropTypes.func.isRequired,
   t: PropTypes.func,
diff --git a/src/containers/CanvasAnnotations.js b/src/containers/CanvasAnnotations.js
index cdd6caaa01507443f8b1e94c730c7a76ed3d8c51..15c7479f49e30f7919010509f8613b795076298c 100644
--- a/src/containers/CanvasAnnotations.js
+++ b/src/containers/CanvasAnnotations.js
@@ -29,9 +29,7 @@ function getIdAndContentOfResources(resources) {
 /** For connect */
 const mapStateToProps = (state, { canvasId, windowId }) => ({
   annotations: getIdAndContentOfResources(
-    getAnnotationResourcesByMotivationForCanvas(
-      state, { canvasId, windowId },
-    ),
+    getAnnotationResourcesByMotivationForCanvas(state, { canvasId, windowId }),
   ),
   autoScroll: getWindow(state, { windowId }).autoScrollAnnotationList,
   htmlSanitizationRuleSet: getConfig(state).annotations.htmlSanitizationRuleSet,
diff --git a/src/containers/ManifestRelatedLinks.js b/src/containers/ManifestRelatedLinks.js
index 005029f220359f4c8f797bef20abacd763cde3c7..6bcc356778bf8d8f6388d25c2e2698462ea1a119 100644
--- a/src/containers/ManifestRelatedLinks.js
+++ b/src/containers/ManifestRelatedLinks.js
@@ -5,8 +5,9 @@ import { withStyles } from '@material-ui/core/styles';
 import { withPlugins } from '../extend/withPlugins';
 import {
   getManifestHomepage,
-  getManifestRelatedContent,
+  getManifestRelated,
   getManifestRenderings,
+  getManifestSeeAlso,
   getManifestUrl,
 } from '../state/selectors';
 import { ManifestRelatedLinks } from '../components/ManifestRelatedLinks';
@@ -19,8 +20,9 @@ import { ManifestRelatedLinks } from '../components/ManifestRelatedLinks';
 const mapStateToProps = (state, { id, windowId }) => ({
   homepage: getManifestHomepage(state, { windowId }),
   manifestUrl: getManifestUrl(state, { windowId }),
+  related: getManifestRelated(state, { windowId }),
   renderings: getManifestRenderings(state, { windowId }),
-  seeAlso: getManifestRelatedContent(state, { windowId }),
+  seeAlso: getManifestSeeAlso(state, { windowId }),
 });
 
 const styles = {
diff --git a/src/containers/SearchHit.js b/src/containers/SearchHit.js
index 9c535f5d59163da10d05a58d5ddeceeb2ed47ca0..60abacf26dfe4560dcbdd509abc4d37ea33a4a1c 100644
--- a/src/containers/SearchHit.js
+++ b/src/containers/SearchHit.js
@@ -24,11 +24,14 @@ const mapStateToProps = (state, {
 }) => {
   const realAnnoId = annotationId || hit.annotations[0];
   const hitAnnotation = getResourceAnnotationForSearchHit(
-    state, { annotationUri: realAnnoId, companionWindowId, windowId },
-  );
-  const annotationLabel = getResourceAnnotationLabel(
-    state, { annotationUri: realAnnoId, companionWindowId, windowId },
+    state,
+    {
+      annotationUri: realAnnoId,
+      companionWindowId,
+      windowId,
+    },
   );
+  const annotationLabel = getResourceAnnotationLabel(state, { annotationUri: realAnnoId, companionWindowId, windowId });
   const selectedCanvasIds = getVisibleCanvasIds(state, { windowId });
 
   const selectedContentSearchAnnotationsIds = getSelectedContentSearchAnnotationIds(state, {
diff --git a/src/containers/WindowAuthenticationBar.js b/src/containers/WindowAuthenticationBar.js
index 3b42a93aa7f44067e0cdc09958a20a90c4059b66..f3b7f8733367a8b7f2adf4c1e35f74d49b3353f1 100644
--- a/src/containers/WindowAuthenticationBar.js
+++ b/src/containers/WindowAuthenticationBar.js
@@ -13,9 +13,7 @@ import { WindowAuthenticationBar } from '../components/WindowAuthenticationBar';
 const styles = theme => ({
   buttonInvert: {
     '&:hover': {
-      backgroundColor: alpha(
-        theme.palette.secondary.contrastText, 1 - theme.palette.action.hoverOpacity,
-      ),
+      backgroundColor: alpha(theme.palette.secondary.contrastText, 1 - theme.palette.action.hoverOpacity),
     },
     backgroundColor: theme.palette.secondary.contrastText,
     marginLeft: theme.spacing(5),
diff --git a/src/lib/CanvasWorld.js b/src/lib/CanvasWorld.js
index cb1e0078ce34bb39f03cc0ab81e229eb957cbda4..7df30b0208bfd547f0a74a86cd79955246c2080e 100644
--- a/src/lib/CanvasWorld.js
+++ b/src/lib/CanvasWorld.js
@@ -136,7 +136,7 @@ export default class CanvasWorld {
   /** Get the IIIF content resource for an image */
   contentResource(infoResponseId) {
     const miradorCanvas = this.canvases.find(c => c.imageServiceIds.some(id => (
-      normalizeUrl(id, { stripAuthentication: false })
+      id && infoResponseId && normalizeUrl(id, { stripAuthentication: false })
         === normalizeUrl(infoResponseId, { stripAuthentication: false }))));
     if (!miradorCanvas) return undefined;
     return miradorCanvas.imageResources
@@ -203,8 +203,8 @@ export default class CanvasWorld {
    * lined up horizontally starting from left to right.
    */
   worldBounds() {
-    const worldWidth = Math.max(...this.canvasDimensions.map(c => c.x + c.width));
-    const worldHeight = Math.max(...this.canvasDimensions.map(c => c.y + c.height));
+    const worldWidth = Math.max(0, ...this.canvasDimensions.map(c => c.x + c.width));
+    const worldHeight = Math.max(0, ...this.canvasDimensions.map(c => c.y + c.height));
 
     return [
       0,
diff --git a/src/lib/MiradorViewer.js b/src/lib/MiradorViewer.js
index c9238b84d9057a70b8c96dd543ca2584177333ed..cd825870d784d8f94c418f620a51c7be3c83836c 100644
--- a/src/lib/MiradorViewer.js
+++ b/src/lib/MiradorViewer.js
@@ -19,10 +19,13 @@ class MiradorViewer {
     this.store = viewerConfig.store
       || createPluggableStore(this.config, this.plugins);
 
-    config.id && ReactDOM.render(
-      this.render(),
-      document.getElementById(config.id),
-    );
+    if (config.id) {
+      this.container = document.getElementById(config.id);
+      config.id && ReactDOM.render(
+        this.render(),
+        this.container,
+      );
+    }
   }
 
   /**
@@ -40,7 +43,7 @@ class MiradorViewer {
    * Cleanup method to unmount Mirador from the dom
    */
   unmount() {
-    this.config.id && ReactDOM.unmountComponentAtNode(document.getElementById(this.config.id));
+    this.container && ReactDOM.unmountComponentAtNode(this.container);
   }
 }
 
diff --git a/src/lib/ThumbnailFactory.js b/src/lib/ThumbnailFactory.js
index 79c063f8852c82b9044640ed28e33d25b8e6de3d..6fb2ce0e9eda7be9d328d9fae3da109e429c276d 100644
--- a/src/lib/ThumbnailFactory.js
+++ b/src/lib/ThumbnailFactory.js
@@ -90,27 +90,23 @@ class ThumbnailFactory {
     const imageFitness = (test) => test.width * test.height - targetArea;
 
     /** Look for the size that's just bigger than we prefer... */
-    closestSize = sizes.reduce(
-      (best, test) => {
-        const score = imageFitness(test);
+    closestSize = sizes.reduce((best, test) => {
+      const score = imageFitness(test);
 
-        if (score < 0) return best;
+      if (score < 0) return best;
 
-        return Math.abs(score) < Math.abs(imageFitness(best))
-          ? test
-          : best;
-      }, closestSize,
-    );
+      return Math.abs(score) < Math.abs(imageFitness(best))
+        ? test
+        : best;
+    }, closestSize);
 
     /** .... but not "too" big; we'd rather scale up an image than download too much */
     if (closestSize.width * closestSize.height > targetArea * 6) {
-      closestSize = sizes.reduce(
-        (best, test) => (
-          Math.abs(imageFitness(test)) < Math.abs(imageFitness(best))
-            ? test
-            : best
-        ), closestSize,
-      );
+      closestSize = sizes.reduce((best, test) => (
+        Math.abs(imageFitness(test)) < Math.abs(imageFitness(best))
+          ? test
+          : best
+      ), closestSize);
     }
 
     if (closestSize.default) return undefined;
@@ -313,4 +309,5 @@ function getBestThumbnail(resource, iiifOpts) {
   return new ThumbnailFactory(resource, iiifOpts).get();
 }
 
-export { getBestThumbnail as default, ThumbnailFactory };
+export { ThumbnailFactory };
+export default getBestThumbnail;
diff --git a/src/lib/TruncatedHit.js b/src/lib/TruncatedHit.js
index f6363cf3abe12678b45649e6c33db396fe1aa568..8317b098434cdb82146e2626a5bef2746bf458de 100644
--- a/src/lib/TruncatedHit.js
+++ b/src/lib/TruncatedHit.js
@@ -25,16 +25,12 @@ export default class TruncatedHit {
   /** */
   get before() {
     if (!this.hit.before) return '';
-    return this.hit.before.substring(
-      this.hit.before.length - this.charsOnSide, this.hit.before.length,
-    );
+    return this.hit.before.substring(this.hit.before.length - this.charsOnSide, this.hit.before.length);
   }
 
   /** */
   get after() {
     if (!this.hit.after) return '';
-    return this.hit.after.substring(
-      0, Math.min(this.hit.after.length, this.charsOnSide),
-    );
+    return this.hit.after.substring(0, Math.min(this.hit.after.length, this.charsOnSide));
   }
 }
diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json
index b1d3ca8b74c2123e5fadf13309ce9e5a181057a0..c27976c6b8f670f492fd23fe5d99ef783d7c38dd 100644
--- a/src/locales/de/translation.json
+++ b/src/locales/de/translation.json
@@ -57,6 +57,7 @@
     "hideZoomControls": "Zoomsteuerung verbergen",
     "iiif_homepage": "Über diese Ressource",
     "iiif_manifest": "IIIF-Manifest",
+    "iiif_related": "Verwandtes",
     "iiif_renderings": "Alternative Formate",
     "iiif_seeAlso": "Siehe auch",
     "import" : "Importieren",
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 1fe15e7418ee188bd100aacc7c2ef2ac7c12bf76..a7f8b699383d61b752cbc12bed961745d294de78 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -59,6 +59,7 @@
     "hideZoomControls": "Hide zoom controls",
     "iiif_homepage": "About this resource",
     "iiif_manifest": "IIIF manifest",
+    "iiif_related": "Related",
     "iiif_renderings": "Alternate formats",
     "iiif_seeAlso": "See also",
     "import" : "Import",
diff --git a/src/state/createStore.js b/src/state/createStore.js
index 717b0b8915cc9dd00df4b0652a56a4d291b80c65..9467f322c5535aab6d73abcd48d0aaed5b3dc9a1 100644
--- a/src/state/createStore.js
+++ b/src/state/createStore.js
@@ -27,9 +27,7 @@ function configureStore(pluginReducers, pluginSagas = []) {
   const store = createStore(
     rootReducer,
     composeWithDevTools(
-      applyMiddleware(
-        thunkMiddleware, sagaMiddleware,
-      ),
+      applyMiddleware(thunkMiddleware, sagaMiddleware),
     ),
   );
 
diff --git a/src/state/sagas/iiif.js b/src/state/sagas/iiif.js
index 41823156bc7b8ac6b423ec9dafe4e8efdb019e53..e661170c5eca3d5fb1a2106fb4c43125afaaec31 100644
--- a/src/state/sagas/iiif.js
+++ b/src/state/sagas/iiif.js
@@ -64,7 +64,10 @@ function* fetchIiifResourceWithAuth(url, iiifResource, options, { degraded, fail
   }
 
   const { error, json, response } = yield call(
-    fetchIiifResource, url, urlOptions, { failure: arg => arg, success: arg => arg },
+    fetchIiifResource,
+    url,
+    urlOptions,
+    { failure: arg => arg, success: arg => arg },
   );
 
   // Hard error either requesting the resource or deserializing the JSON.
diff --git a/src/state/sagas/windows.js b/src/state/sagas/windows.js
index bc63cdcbb9a072fbdb5f4dd4dd23890d92974823..bfcd275aaadc06cc862791933b86f5f078ed4e90 100644
--- a/src/state/sagas/windows.js
+++ b/src/state/sagas/windows.js
@@ -94,9 +94,7 @@ export function* setWindowStartingCanvas(action) {
   const windowId = action.id || action.window.id;
 
   if (canvasId) {
-    const thunk = yield call(
-      setCanvas, windowId, canvasId, null, { preserveViewport: !!action.payload },
-    );
+    const thunk = yield call(setCanvas, windowId, canvasId, null, { preserveViewport: !!action.payload });
     yield put(thunk);
   } else {
     const manifestoInstance = yield select(getManifestoInstance, { manifestId });
@@ -157,7 +155,12 @@ export function* setCurrentAnnotationsOnCurrentCanvas({
   if (companionWindowIds.length === 0) return;
 
   const annotationBySearch = yield select(
-    getAnnotationsBySearch, { canvasIds: visibleCanvases, companionWindowIds, windowId },
+    getAnnotationsBySearch,
+    {
+      canvasIds: visibleCanvases,
+      companionWindowIds,
+      windowId,
+    },
   );
 
   yield all(
@@ -213,9 +216,7 @@ export function* setCanvasOfFirstSearchResult({ companionWindowId, windowId }) {
 
   if (selectedIds.length !== 0) return;
 
-  const annotations = yield select(
-    getSortedSearchAnnotationsForCompanionWindow, { companionWindowId, windowId },
-  );
+  const annotations = yield select(getSortedSearchAnnotationsForCompanionWindow, { companionWindowId, windowId });
   if (!annotations || annotations.length === 0) return;
 
   yield put(selectAnnotation(windowId, annotations[0].id));
diff --git a/src/state/selectors/manifests.js b/src/state/selectors/manifests.js
index 44abe0435266fe581b624657d5207af2e5076a33..48f25eefc77bfb2466c90ceb33435beb3c8e19f2 100644
--- a/src/state/selectors/manifests.js
+++ b/src/state/selectors/manifests.js
@@ -153,13 +153,14 @@ export const getManifestRenderings = createSelector(
 
 /**
 * Return the IIIF v2/v3 seeAlso data from a manifest or null
+*
 * @param {object} state
 * @param {object} props
 * @param {string} props.manifestId
 * @param {string} props.windowId
 * @return {String|null}
 */
-export const getManifestRelatedContent = createSelector(
+export const getManifestSeeAlso = createSelector(
   [
     getProperty('seeAlso'),
     getManifestLocale,
@@ -175,6 +176,48 @@ export const getManifestRelatedContent = createSelector(
     )),
 );
 
+/**
+* Return the IIIF v2/v3 seeAlso data from a manifest or null
+*
+* @param {object} state
+* @param {object} props
+* @param {string} props.manifestId
+* @param {string} props.windowId
+* @return {String|null}
+* @deprecated This does not actually return the content of "related" and
+* might be removed in a future version.
+* @see getManifestSeeAlso
+*/
+export const getManifestRelatedContent = getManifestSeeAlso;
+
+/**
+* Return the IIIF v2 realated links manifest or null
+* @param {object} state
+* @param {object} props
+* @param {string} props.manifestId
+* @param {string} props.windowId
+* @return {String|null}
+*/
+export const getManifestRelated = createSelector(
+  [
+    getProperty('related'),
+    getManifestLocale,
+  ],
+  (relatedLinks, locale) => relatedLinks
+    && asArray(relatedLinks).map(related => (
+      typeof related === 'string'
+        ? {
+          value: related,
+        }
+        : {
+          format: related.format,
+          label: PropertyValue.parse(related.label, locale)
+            .getValue(),
+          value: related.id || related['@id'],
+        }
+    )),
+);
+
 /**
 * Return the IIIF requiredStatement (v3) or attribution (v2) data from a manifest or null
 * @param {object} state
diff --git a/src/state/selectors/searches.js b/src/state/selectors/searches.js
index a1cac26ae0313ea64a92340df167bef478fbd961..11f9feaf73c68708a11856a17f2f34421b0d7c15 100644
--- a/src/state/selectors/searches.js
+++ b/src/state/selectors/searches.js
@@ -111,8 +111,8 @@ export const getSortedSearchHitsForCompanionWindow = createSelector(
   [
     getSearchHitsForCompanionWindow,
     getCanvases,
-    (state, { companionWindowId, windowId }) => annotationUri => getResourceAnnotationForSearchHit(
-      state, { annotationUri, companionWindowId, windowId },
+    (state, { companionWindowId, windowId }) => (
+      annotationUri => getResourceAnnotationForSearchHit(state, { annotationUri, companionWindowId, windowId })
     ),
   ],
   (searchHits, canvases, annotationForSearchHit) => {
@@ -232,9 +232,7 @@ const getAnnotationById = createSelector(
 export const getCanvasForAnnotation = createSelector(
   [
     getAnnotationById,
-    (state, { windowId }) => canvasId => getCanvas(
-      state, { canvasId, windowId },
-    ),
+    (state, { windowId }) => canvasId => getCanvas(state, { canvasId, windowId }),
   ],
   (annotation, getCanvasById) => {
     const canvasId = annotation && annotation.targetId;
diff --git a/webpack.config.js b/webpack.config.js
index 3f78b6cad3e2a3339c9801046e1b21ef5bb89b0a..8c4e2eaf0637d3348b43251a47cd32f59d0fe097 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -27,12 +27,12 @@ const baseConfig = mode => ({
     minimizer: [
       new TerserPlugin({
         extractComments: true,
-        sourceMap: true,
       }),
     ],
   },
   output: {
     filename: 'mirador.min.js',
+    hashFunction: 'md5',
     library: 'Mirador',
     libraryExport: 'default',
     libraryTarget: 'umd',
@@ -74,12 +74,12 @@ module.exports = (env, options) => {
   return {
     ...config,
     devServer: {
-      contentBase: [
+      hot: true,
+      port: 4444,
+      static: [
         './__tests__/integration/mirador',
         './__tests__/fixtures',
       ],
-      hot: true,
-      port: 4444,
     },
     devtool: 'eval-source-map',
     mode: 'development',