This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new f66843ef0c7 fix(plugin-chart-table): stop narrowing sticky
header/footer width (#43937)
f66843ef0c7 is described below
commit f66843ef0c77ae195b3dcc6a36b89ce38b11a8e1
Author: Joe Li <[email protected]>
AuthorDate: Wed Sep 9 09:47:27 2026 -0700
fix(plugin-chart-table): stop narrowing sticky header/footer width (#43937)
---
.../src/DataTable/hooks/useSticky.tsx | 35 +++-
.../test/DataTable/hooks/useSticky.test.tsx | 205 +++++++++++++++++++++
.../test/DataTable/utils/getScrollBarSize.test.ts | 6 +-
3 files changed, 238 insertions(+), 8 deletions(-)
diff --git
a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx
b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx
index 65231bc652d..eee392e0c0e 100644
---
a/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx
+++
b/superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+
+/** @jsxImportSource @emotion/react */
import {
Children,
cloneElement,
@@ -286,9 +288,28 @@ function StickyWrap({
</colgroup>
);
- const headerContainerWidth = hasVerticalScroll
- ? maxWidth - scrollBarSize
- : maxWidth;
+ // Below, `width: maxWidth` is applied unconditionally (never reduced by
+ // subtracting a separately-measured scrollbar width, unlike this file's
+ // previous `maxWidth - scrollBarSize`). That's the load-bearing part of
+ // this fix: the shared colgroup (computed from the sizer below, whose
+ // own clientWidth can only ever be <= maxWidth) can never need more
+ // width than that, so a header/footer wrapper that's never narrowed
+ // below maxWidth can never clip it, regardless of whether any
+ // JS-measured scrollbar size agrees with what the sizer/body actually
+ // reserve in a given browser.
+ //
+ // `scrollbarGutter`/`scrollBarStyles` below are a separate, secondary
+ // measure -- matching an actual clip boundary is not what they're for
+ // (an `overflow: hidden` box's clip boundary sits at its real
+ // border-box edge regardless of `scrollbar-gutter`, which only affects
+ // what `clientWidth` reports). They keep header/footer's reported
+ // `clientWidth` consistent with body's so that, when both a vertical
+ // and a horizontal scrollbar are present, the horizontal `scrollLeft`
+ // synced from body (see `onScroll` below) reveals the same slice of the
+ // row in header/footer as is actually visible in body.
+ const headerFooterGutter: CSSProperties = {
+ scrollbarGutter: hasVerticalScroll ? 'stable' : undefined,
+ };
headerTable = (
<div
@@ -296,9 +317,11 @@ function StickyWrap({
ref={scrollHeaderRef}
style={{
overflow: 'hidden',
- width: headerContainerWidth,
+ width: maxWidth,
boxSizing: 'border-box',
+ ...headerFooterGutter,
}}
+ css={scrollBarStyles}
role="presentation"
>
{cloneElement(
@@ -317,9 +340,11 @@ function StickyWrap({
ref={scrollFooterRef}
style={{
overflow: 'hidden',
- width: headerContainerWidth,
+ width: maxWidth,
boxSizing: 'border-box',
+ ...headerFooterGutter,
}}
+ css={scrollBarStyles}
role="presentation"
>
{cloneElement(
diff --git
a/superset-frontend/plugins/plugin-chart-table/test/DataTable/hooks/useSticky.test.tsx
b/superset-frontend/plugins/plugin-chart-table/test/DataTable/hooks/useSticky.test.tsx
new file mode 100644
index 00000000000..d75eca12fc4
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-table/test/DataTable/hooks/useSticky.test.tsx
@@ -0,0 +1,205 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useCallback } from 'react';
+import { useTable, Column } from 'react-table';
+import { render } from '@superset-ui/core/spec';
+import useSticky from '../../../src/DataTable/hooks/useSticky';
+
+// A value distinguishable from any real scrollbar width, so the width
+// assertions below can detect whether header/footer's wrapper width was
+// computed by subtracting this JS-measured probe from `maxWidth` (the old,
+// removed `maxWidth - scrollBarSize` behavior) rather than always being the
+// unconditional `maxWidth` the fix uses. If that subtraction is ever
+// reintroduced, header/footer's `style.width` would read
+// `${MAX_WIDTH - MOCKED_SCROLLBAR_PROBE_SIZE}px`, an unmistakably wrong
+// value given how large this mock is.
+const MOCKED_SCROLLBAR_PROBE_SIZE = 42;
+
+jest.mock('../../../src/DataTable/utils/getScrollBarSize', () => ({
+ __esModule: true,
+ CUSTOM_SCROLLBAR_SIZE: 8,
+ default: () => 0,
+ getCustomScrollBarSize: () => MOCKED_SCROLLBAR_PROBE_SIZE,
+}));
+
+const MAX_WIDTH = 300;
+const MAX_HEIGHT = 120; // small enough that the mocked content forces a
vertical scroll
+
+const TOTAL_HEADER_HEIGHT = 30;
+const TOTAL_FOOTER_HEIGHT = 30;
+// Larger than `MAX_HEIGHT - TOTAL_HEADER_HEIGHT - TOTAL_FOOTER_HEIGHT`, so the
+// sticky layout effect computes `hasVerticalScroll: true`.
+const FULL_TABLE_HEIGHT = 400;
+
+function mockMeasurements() {
+ jest
+ .spyOn(HTMLElement.prototype, 'clientHeight', 'get')
+ .mockImplementation(function mockClientHeight(this: HTMLElement) {
+ if (this.tagName === 'THEAD') return TOTAL_HEADER_HEIGHT;
+ if (this.tagName === 'TFOOT') return TOTAL_FOOTER_HEIGHT;
+ if (this.tagName === 'TABLE') return FULL_TABLE_HEIGHT;
+ return 0;
+ });
+ jest
+ .spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+ .mockImplementation(function mockRect(this: HTMLElement) {
+ const width = this.tagName === 'TH' ? 60 : 0;
+ return {
+ width,
+ height: 0,
+ top: 0,
+ left: 0,
+ right: width,
+ bottom: 0,
+ x: 0,
+ y: 0,
+ toJSON: () => {},
+ } as DOMRect;
+ });
+}
+
+type Row = { category: string; amount: string };
+
+const columns: Column<Row>[] = [
+ { Header: 'Category', accessor: 'category' },
+ { Header: 'SUM(amount)', accessor: 'amount' },
+];
+
+const data: Row[] = Array.from({ length: 8 }, (_, i) => ({
+ category: `Category ${i}`,
+ amount: `${1234567.891234 + i}`,
+}));
+
+function StickyTableHarness() {
+ const getTableSize = useCallback(
+ () => ({ width: MAX_WIDTH, height: MAX_HEIGHT }),
+ [],
+ );
+ const { getTableProps, headerGroups, rows, prepareRow, wrapStickyTable } =
+ useTable<Row>(
+ {
+ columns,
+ data,
+ getTableSize,
+ },
+ useSticky,
+ );
+
+ const renderTable = () => (
+ <table {...getTableProps()}>
+ <thead>
+ {headerGroups.map(hg => (
+ <tr {...hg.getHeaderGroupProps()} key={hg.id}>
+ {hg.headers.map(col => (
+ <th {...col.getHeaderProps()} key={col.id}>
+ {col.render('Header')}
+ </th>
+ ))}
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {rows.map(row => {
+ prepareRow(row);
+ return (
+ <tr {...row.getRowProps()} key={row.id}>
+ {row.cells.map(cell => (
+ <td {...cell.getCellProps()} key={cell.column.id}>
+ {cell.render('Cell')}
+ </td>
+ ))}
+ </tr>
+ );
+ })}
+ </tbody>
+ <tfoot>
+ <tr key="footer">
+ <th>Summary</th>
+ <td>
+ <strong>14814904.694808</strong>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+ );
+
+ return <div data-test="sticky-root">{wrapStickyTable(renderTable)}</div>;
+}
+
+test('sticky header/footer width matches the body, independent of the
scrollbar-size probe', () => {
+ mockMeasurements();
+
+ const { container } = render(<StickyTableHarness />);
+
+ const root = container.querySelector('[data-test="sticky-root"] > div');
+ expect(root).not.toBeNull();
+ const [headerDiv, bodyDiv, footerDiv] = Array.from(
+ root!.children,
+ ) as HTMLDivElement[];
+
+ expect(bodyDiv.style.width).toBe(`${MAX_WIDTH}px`);
+
+ // This is the load-bearing assertion for the reported bug. Before the fix
+ // these read `${MAX_WIDTH - MOCKED_SCROLLBAR_PROBE_SIZE}px` (258px) --
+ // genuinely narrower than the body, from a real CSS `width` subtraction
+ // (`maxWidth - scrollBarSize`), not just a smaller reported `clientWidth`.
+ // A wrapper that's actually narrower than the shared, fixed-layout
+ // colgroup it has to display gets genuinely clipped by its own
+ // `overflow: hidden` (verified with real hit-testing in a real browser --
+ // this is not true of the `scrollbarGutter` assertions below). The fix
+ // makes header/footer always exactly `maxWidth`, which the colgroup
+ // (bounded by the sizer's `clientWidth`, itself bounded by `maxWidth`)
+ // can never exceed.
+ expect(headerDiv.style.width).toBe(`${MAX_WIDTH}px`);
+ expect(footerDiv.style.width).toBe(`${MAX_WIDTH}px`);
+
+ // Secondary, not itself load-bearing for preventing clipping: real
+ // hit-testing shows `scrollbar-gutter` on an `overflow: hidden` box
+ // changes what `clientWidth` reports without moving where it actually
+ // clips, so this doesn't guard against the reported bug by itself. It's
+ // asserted because header/footer's reported `clientWidth` still needs to
+ // match body's `clientWidth` for their programmatically
+ // synced `scrollLeft` (see `onScroll` in `useSticky.tsx`) to reveal the
+ // same slice of the row body actually shows, when a horizontal scrollbar
+ // is present alongside a vertical one.
+ expect(headerDiv.style.scrollbarGutter).toBe(bodyDiv.style.scrollbarGutter);
+ expect(footerDiv.style.scrollbarGutter).toBe(bodyDiv.style.scrollbarGutter);
+ expect(bodyDiv.style.scrollbarGutter).toBe('stable');
+
+ // Pin the `css={scrollBarStyles}` addition to header/footer directly (part
+ // of the same secondary consistency measure as the `scrollbarGutter`
+ // assertions above, not the clipping fix). This component carries
+ // `/** @jsxImportSource @emotion/react */`, which makes
+ // Babel route its `css` prop through Emotion's jsx runtime instead of
+ // passing `css` straight through as an inert DOM attribute (the default in
+ // this repo's Jest/Babel setup, which -- unlike the webpack/SWC build --
+ // doesn't set `importSource: '@emotion/react'` globally). With the pragma
+ // in place, an applied `css` prop is observable as a real, non-empty
+ // className, so this assertion actually fails without the fix instead of
+ // passing regardless of whether `scrollBarStyles` is wired up.
+ //
+ // Before `css={scrollBarStyles}` was added to header/footer, they had no
+ // emotion-generated class at all (`className === ''`) while the body kept
+ // its own -- so this fails pre-fix and passes post-fix.
+ expect(headerDiv.className).not.toBe('');
+ expect(headerDiv.className).toBe(bodyDiv.className);
+ expect(footerDiv.className).toBe(bodyDiv.className);
+
+ jest.restoreAllMocks();
+});
diff --git
a/superset-frontend/plugins/plugin-chart-table/test/DataTable/utils/getScrollBarSize.test.ts
b/superset-frontend/plugins/plugin-chart-table/test/DataTable/utils/getScrollBarSize.test.ts
index 7ad2d83b800..8284d6bb292 100644
---
a/superset-frontend/plugins/plugin-chart-table/test/DataTable/utils/getScrollBarSize.test.ts
+++
b/superset-frontend/plugins/plugin-chart-table/test/DataTable/utils/getScrollBarSize.test.ts
@@ -45,8 +45,8 @@ test('getCustomScrollBarSize measures the probe using the
shared custom scrollba
});
test('CUSTOM_SCROLLBAR_SIZE matches the custom scrollbar width rendered in the
sticky table', () => {
- // useSticky.tsx's scrollBarStyles must stay in sync with this constant so
- // the sticky header's shrink amount always matches the body's real
- // scrollbar width.
+ // useSticky.tsx's scrollBarStyles sets `::-webkit-scrollbar { width: ... }`
+ // from this constant, so it must stay in sync with it or the real
+ // scrollbar body/sizer render won't match what this constant claims.
expect(CUSTOM_SCROLLBAR_SIZE).toBe(8);
});