Take scrollbars into account when computing Dataframe dimensions
diff --git a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals0.snap.png b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals0.snap.png
index 1a6023cb9bdc..29668546b380 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals0.snap.png and b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals0.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals4.snap.png b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals4.snap.png
index 89f4f5455a96..0b467fd3baf9 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals4.snap.png and b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals4.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals5.snap.png b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals5.snap.png
index 6a066efb9188..7d40173f3754 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals5.snap.png and b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals5.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals6.snap.png b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals6.snap.png
index d1e415b4395c..da4439b1445d 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals6.snap.png and b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals6.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals7.snap.png b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals7.snap.png
index 91e51339787d..65b96c99c7d2 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals7.snap.png and b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals7.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals8.snap.png b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals8.snap.png
index e08f023a77ec..8e4db7bfa50a 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals8.snap.png and b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals8.snap.png differ
diff --git a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals9.snap.png b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals9.snap.png
index a7dabf4bec93..4e1f550d471d 100644
Binary files a/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals9.snap.png and b/frontend/cypress/snapshots/linux/2x/st_dataframe_sizes.spec.js/dataframe-visuals9.snap.png differ
diff --git a/frontend/src/components/elements/DataFrame/DataFrame.test.tsx b/frontend/src/components/elements/DataFrame/DataFrame.test.tsx
index 1605edb091a8..179ace091b60 100644
--- a/frontend/src/components/elements/DataFrame/DataFrame.test.tsx
+++ b/frontend/src/components/elements/DataFrame/DataFrame.test.tsx
@@ -18,11 +18,17 @@
 import React from "react"
 import { shallow } from "enzyme"
 import { fromJS } from "immutable"
+import { random, times } from "lodash"
 
 import mockDataFrame from "./mock"
 import { DataFrame, DataFrameProps } from "./DataFrame"
 import { MIN_CELL_WIDTH_PX } from "./DataFrameUtil"
 
+const SCROLLBAR_SIZE = 10
+jest.mock("vendor/dom-helpers", () => ({
+  scrollbarSize: () => SCROLLBAR_SIZE,
+}))
+
 const getProps = (
   elementProps: Record<string, unknown> = {}
 ): DataFrameProps => ({
@@ -34,6 +40,20 @@ const getProps = (
   height: 400,
 })
 
+const fakeData = (
+  numRows: number,
+  numCols: number
+): Partial<typeof mockDataFrame> => ({
+  data: {
+    cols: times(numCols, () => ({
+      int64s: { data: times(numRows, () => random(0, 9)) },
+      type: "int64s",
+    })),
+  },
+  index: { rangeIndex: { start: 0, stop: numRows }, type: "rangeIndex" },
+  columns: { rangeIndex: { start: 0, stop: numCols }, type: "rangeIndex" },
+})
+
 describe("DataFrame Element", () => {
   const props = getProps()
   const wrapper = shallow(<DataFrame {...props} />)
@@ -56,10 +76,12 @@ describe("DataFrame Element", () => {
     expect(multiGridProps.columnCount).toBe(11)
     expect(multiGridProps).toHaveProperty("enableFixedColumnScroll")
     expect(multiGridProps).toHaveProperty("enableFixedRowScroll")
-    expect(multiGridProps.height).toBe(275)
+    // 275px for the dataframe itself + 10px for the horizontal scrollbar
+    expect(multiGridProps.height).toBe(285)
     expect(multiGridProps.rowHeight).toBe(25)
     expect(multiGridProps.rowCount).toBe(11)
-    expect(multiGridProps.width).toBe(398)
+    // 400px full container width - 12px for border and vertical scrollbar
+    expect(multiGridProps.width).toBe(388)
   })
 
   it("should render as empty if there's no data", () => {
@@ -81,4 +103,30 @@ describe("DataFrame Element", () => {
     expect(multiGridProps.rowCount).toBe(1)
     expect(multiGridProps.width).toBe(60)
   })
+
+  it("adds extra height for horizontal scrollbar when wide but not tall", () => {
+    let props = getProps({ ...fakeData(1, 1) })
+    let wrapper = shallow(<DataFrame {...props} />)
+    const normalHeight = wrapper.find("MultiGrid").props().height
+
+    props = getProps({ ...fakeData(1, 20) })
+    wrapper = shallow(<DataFrame {...props} />)
+    const heightWithScrollbar = wrapper.find("MultiGrid").props().height
+
+    expect(heightWithScrollbar).toBe(normalHeight + SCROLLBAR_SIZE)
+  })
+
+  it("adds extra width for vertical scrollbar when tall but not wide", () => {
+    // Be careful to ensure that the number of digits needed to display the
+    // largest row number is the same for the two DataFrames.
+    let props = getProps({ ...fakeData(11, 1) })
+    let wrapper = shallow(<DataFrame {...props} />)
+    const normalWidth = wrapper.find("MultiGrid").props().width
+
+    props = getProps({ ...fakeData(99, 1) })
+    wrapper = shallow(<DataFrame {...props} />)
+    const widthWithScrollbar = wrapper.find("MultiGrid").props().width
+
+    expect(widthWithScrollbar).toBe(normalWidth + SCROLLBAR_SIZE)
+  })
 })
diff --git a/frontend/src/components/elements/DataFrame/DataFrameUtil.tsx b/frontend/src/components/elements/DataFrame/DataFrameUtil.tsx
index 1a32fc954c0f..2dfe6c35b591 100644
--- a/frontend/src/components/elements/DataFrame/DataFrameUtil.tsx
+++ b/frontend/src/components/elements/DataFrame/DataFrameUtil.tsx
@@ -5,6 +5,7 @@ import {
 } from "lib/dataFrameProto"
 import { toFormattedString } from "lib/format"
 import { logWarning } from "lib/log"
+import { scrollbarSize } from "vendor/dom-helpers"
 import React, { ReactElement, ComponentType } from "react"
 import { Map as ImmutableMap } from "immutable"
 import {
@@ -73,6 +74,7 @@ interface ComputedWidths {
   elementWidth: number
   columnWidth: ({ index }: { index: number }) => number
   headerWidth: number
+  needsHorizontalScrollbar: boolean
 }
 
 const DEFAULT_HEIGHT = 300
@@ -99,15 +101,21 @@ export const getDimensions = (
   const headerHeight = rowHeight * headerRows
   const border = 2
 
-  let { elementWidth, columnWidth, headerWidth } = getWidths(
+  // Reserve enough space to render the dataframe border as well as a vertical
+  // scrollbar if necessary.
+  const availableWidth = width - border - scrollbarSize()
+  const widths = getWidths(
     cols,
     rows,
     headerCols,
     headerRows,
-    width - border,
+    availableWidth,
     cellContentsGetter
   )
 
+  let { elementWidth, columnWidth, headerWidth } = widths
+  const { needsHorizontalScrollbar } = widths
+
   // Add space for the "empty" text when the table is empty.
   const EMPTY_WIDTH = 60 // px
   if (dataRows === 0 && elementWidth < EMPTY_WIDTH) {
@@ -122,14 +130,24 @@ export const getDimensions = (
     }
   }
 
+  // Allocate extra space for horizontal and vertical scrollbars, if needed.
+  const totalHeight = rows * rowHeight
+  const maxHeight = height || DEFAULT_HEIGHT
+
+  const horizScrollbarHeight = needsHorizontalScrollbar ? scrollbarSize() : 0
+  height = Math.min(totalHeight + horizScrollbarHeight, maxHeight)
+
+  const needsVerticalScrollbar = totalHeight > maxHeight
+  elementWidth += needsVerticalScrollbar ? scrollbarSize() : 0
+
   return {
     rowHeight,
     headerHeight,
     border,
-    height: Math.min(rows * rowHeight, height || DEFAULT_HEIGHT),
-    elementWidth,
     columnWidth,
     headerWidth,
+    elementWidth,
+    height,
   }
 }
 
@@ -288,6 +306,7 @@ export function getWidths(
   }
 
   const elementWidth = Math.min(distributedTableTotal, containerWidth)
+  const needsHorizontalScrollbar = distributedTableTotal > containerWidth
   const columnWidth = ({ index }: { index: number }): number =>
     distributedTable[index]
 
@@ -299,5 +318,6 @@ export function getWidths(
     elementWidth,
     columnWidth,
     headerWidth,
+    needsHorizontalScrollbar,
   }
 }
diff --git a/frontend/src/vendor/dom-helpers.ts b/frontend/src/vendor/dom-helpers.ts
new file mode 100644
index 000000000000..4cc267a6f098
--- /dev/null
+++ b/frontend/src/vendor/dom-helpers.ts
@@ -0,0 +1,33 @@
+/* eslint-disable */
+
+// We only need a single function from https://github.com/react-bootstrap/dom-helpers,
+// so we copy it here instead of adding a new dependency.
+
+const canUseDOM = !!(
+  typeof window !== "undefined" &&
+  window.document &&
+  window.document.createElement
+)
+
+let size: number
+
+// https://github.com/react-bootstrap/dom-helpers/blob/3f509a03c5e330faa93bcf8acf30976b5a7bacac/src/scrollbarSize.ts#L5
+export function scrollbarSize(recalc?: boolean) {
+  if ((!size && size !== 0) || recalc) {
+    if (canUseDOM) {
+      const scrollDiv = document.createElement("div")
+
+      scrollDiv.style.position = "absolute"
+      scrollDiv.style.top = "-9999px"
+      scrollDiv.style.width = "50px"
+      scrollDiv.style.height = "50px"
+      scrollDiv.style.overflow = "scroll"
+
+      document.body.appendChild(scrollDiv)
+      size = scrollDiv.offsetWidth - scrollDiv.clientWidth
+      document.body.removeChild(scrollDiv)
+    }
+  }
+
+  return size
+}

It turns out the bug in #2543 appears only when there are enough elements in a Dataframe for a scrollbar to be necessary to display all the data. In the case where we need a vertical scrollbar, the width that we provide to the `MultiGrid` we're using to render our Dataframe ends up being slightly too small due to the presence of the scrollbar. This causes a horizontal scrollbar to appear. The other case is analogous.

To fix this, we borrowed a helper function from [react-bootstrap/dom-helpers](https://github.com/react-bootstrap/dom-helpers) for getting the width of scrollbars, then used the helper to fix our rendering dimensions calculations to take scrollbars into account.

Closes #2543