Compare commits

...
Author SHA1 Message Date
sadpandajoe d9aef2ffe5 fix(plugin-chart-table): remove dangling RCA.md references in sticky test comments
RCA.md was removed from the repo (internal process artifact, not meant to
ship publicly), but two comments in useSticky.test.tsx still pointed to it
with "see RCA.md". Drop those dangling parentheticals; the surrounding
comment text already carries the substantive explanation on its own.

Refs #21063
2026-09-07 04:17:58 +00:00
sadpandajoe 4942406798 chore(plugin-chart-table): drop internal RCA notes not meant for the public repo 2026-09-05 18:14:57 +00:00
sadpandajoe 4f44ae5441 fix(plugin-chart-table): correct last probe-vs-gutter framing in sticky test comment
The MOCKED_SCROLLBAR_PROBE_SIZE comment still described the mock as
distinguishing the JS probe from the CSS gutter reservation, the same
probe-vs-gutter framing already retracted and corrected everywhere else in
this file and RCA.md. Reword it to match: the mock detects reintroduction of
the removed maxWidth - scrollBarSize subtraction, not a gutter/probe
mismatch.

Refs #21063
2026-09-05 18:07:29 +00:00
sadpandajoe 7eaed06b08 fix(plugin-chart-table): clarify load-bearing vs secondary fix, remove any casts in sticky test
Some comments (useSticky.tsx, useSticky.test.tsx, RCA.md) still framed the
scrollbar-gutter/scrollBarStyles matching as the mechanism that prevents the
reported clipping, contradicting this branch's own hit-testing conclusion
that it's the unconditional width: maxWidth change that's load-bearing, with
gutter/style matching kept only as an unverified, secondary consistency
measure for combined horizontal+vertical scrolling. Rewrite those comments
and RCA sections to say so explicitly, and fix a getScrollBarSize.test.ts
comment that still described the removed maxWidth - scrollBarSize
subtraction as the header's "shrink amount".

Also remove the `as any` casts from the sticky regression test: type
`columns` as `Column<Row>[]` and pass `useSticky` directly, letting
`headerGroups`/`rows`/`cells` infer their existing react-table types (already
declared in `src/DataTable/types/react-table.d.ts`) instead of bypassing
type-checking of the exercised contract.

Refs #21063
2026-09-05 18:03:43 +00:00
sadpandajoe 0246d4599e fix(plugin-chart-table): verify sticky totals fix with real hit-testing, pin scrollBarStyles regression
The previous round's RCA claimed clipping based on comparing clientWidth to
scrollWidth on an overflow:hidden element, but scrollbar-gutter's reserved
space is phantom for overflow:hidden (never rendered as a real scrollbar),
so it doesn't move the actual clip boundary. Real-browser hit-testing
(document.elementFromPoint sweeps in headed Chromium) shows that comparison
doesn't demonstrate clipping. Retract that claim in RCA.md and replace it
with the structural reason the fix is correct regardless of any browser's
scrollbar-probe accuracy: the shared colgroup is always bounded by the
sizer's clientWidth, which is always bounded by maxWidth, so an unconditional
width: maxWidth on header/footer can never be narrower than the table it
must display.

Also add a /** @jsxImportSource @emotion/react */ pragma to useSticky.tsx so
its css prop is observable in this repo's Jest/Babel setup the same way
webpack/SWC already observes it in production, and use that to extend the
regression test to assert header/footer actually receive the
scrollBarStyles-generated class (previously only the inline width/
scrollbarGutter half was pinned, so the test passed with or without the
scrollBarStyles addition).

Refs #21063
2026-09-05 17:44:03 +00:00
sadpandajoe 0270e32719 fix(plugin-chart-table): match scrollbar styling on sticky header/footer
scrollbar-gutter: stable's reserved width depends on whether custom
::-webkit-scrollbar styling is applied to that element (verified in both
headless and headed Chromium: 15px unstyled vs 8px styled, matching
CUSTOM_SCROLLBAR_SIZE). The previous fix reserved the gutter on the
header/footer wrappers without also giving them the same custom scrollbar
CSS the body/sizer carry, so they reserved a different (larger) amount and
could still clip the shared colgroup's rightmost column.

Also corrects the RCA: real, non-headless browser testing shows unmodified
master's original scrollBarSize-based computation does not diverge from
the body/sizer's reservation under any real condition reproduced in this
investigation (only headless Chromium's synthetic scrollbar probe showed a
difference, which turned out to be a headless-only rendering artifact, not
representative of a real user's browser). The fix still removes a real,
demonstrated defect class -- relying on two independently-measured
scrollbar-space numbers to agree -- rather than the exact historical
trigger, which remains unpinned.
2026-09-05 17:12:57 +00:00
sadpandajoe dd6a9099bc fix(plugin-chart-table): stop clipping sticky totals row on resize
The sticky header/footer wrapper width was narrowed by a separately
JS-measured scrollbar size, while the column widths shared via colgroup
were computed against the browser's own `scrollbar-gutter: stable`
reservation used by the body div. These two numbers aren't guaranteed to
agree, and when the probe overstates the real reservation the fixed-layout
table overflows its `overflow: hidden` wrapper, clipping the rightmost
column -- typically the totals row once a chart is resized enough to need
a vertical scrollbar.

Header and footer now reserve space via the same `scrollbar-gutter`
property the body already uses, so all three always agree.

Fixes #21063
2026-09-05 16:47:23 +00:00
3 changed files with 238 additions and 8 deletions
@@ -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(
@@ -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();
});
@@ -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);
});