diff --git a/.eslintrc b/.eslintrc
index 2dc55b0ec53755829dac743c83ed5d776d174676..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,6 +28,8 @@
       "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",
     "max-len": ["error", {
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/__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/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/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/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 95e24df1a37ef6281bee1149ddec896302ff40fb..500ddff3b4979e7c7d272f05e994b132d220a7f2 100644
--- a/__tests__/src/lib/MiradorViewer.test.js
+++ b/__tests__/src/lib/MiradorViewer.test.js
@@ -30,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);
@@ -74,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/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 71e3e16ee133e18ccca49b8f41da25143ffc0c8f..fc73760539b35499cf110a984fc2bc76609bd3b3 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -53,6 +53,8 @@ module.exports = function (api) {
         loose: true,
       },
     ],
+    ['@babel/plugin-proposal-private-property-in-object', { loose: true }],
+    ['@babel/plugin-proposal-private-methods', { loose: true }],
     [
       '@babel/plugin-proposal-object-rest-spread',
       {
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 cb2499fafc99afba02a93cd78169d89de5bccdee..01e0104c8f35f185197f00ec298b623dd7bcdfd2 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 eb527f1d9f7a48679fcf88b544166120426bf968..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(
       (
diff --git a/src/components/CanvasAnnotations.js b/src/components/CanvasAnnotations.js
index 137a4ef0bcf3f1ed42a78e90ef5df08073752b2e..37263106c9ae5913124c29c44f1bf6b0318e98a0 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/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/OpenSeadragonViewer.js b/src/components/OpenSeadragonViewer.js
index d6353a624e52c31d715a76b1541b4f43e734c8ce..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>
     );
   }
 }
diff --git a/src/components/PrimaryWindow.js b/src/components/PrimaryWindow.js
index 552681b8afaa228012d7332d67fb55a02422a2f0..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) {
diff --git a/src/components/SearchPanelNavigation.js b/src/components/SearchPanelNavigation.js
index 355a0787b4a29d3b171eec044dc8eab8eaf9d05e..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>
     );
   }
 }
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 7b049704005b73cd073ea4ba67f153af4ff8f0e9..e7c6db2945535d7a293694054acb970da4307316 100644
--- a/src/components/ThumbnailNavigation.js
+++ b/src/components/ThumbnailNavigation.js
@@ -183,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/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/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/containers/CanvasAnnotations.js b/src/containers/CanvasAnnotations.js
index 7c9caa748aa43713f8f59b1928fbf8c94a959957..5bebeae7885399b8685ac82bab9462b38020add4 100644
--- a/src/containers/CanvasAnnotations.js
+++ b/src/containers/CanvasAnnotations.js
@@ -28,9 +28,7 @@ function getIdAndContentOfResources(resources) {
 /** For connect */
 const mapStateToProps = (state, { canvasId, windowId }) => ({
   annotations: getIdAndContentOfResources(
-    getAnnotationResourcesByMotivationForCanvas(
-      state, { canvasId, windowId },
-    ),
+    getAnnotationResourcesByMotivationForCanvas(state, { canvasId, windowId }),
   ),
   htmlSanitizationRuleSet: getConfig(state).annotations.htmlSanitizationRuleSet,
   label: getCanvasLabel(state, {
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 acdd5c24539355d5df2166c0699c36d5d57302c1..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
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/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/searches.js b/src/state/selectors/searches.js
index 3e541084e325e3ca41cd962cf119dc91cf15af3a..ae63e2a090618dfbcfaf2a988405f403875e7cea 100644
--- a/src/state/selectors/searches.js
+++ b/src/state/selectors/searches.js
@@ -108,8 +108,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) => {
@@ -229,9 +229,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',