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
26 KiB
RCA: Table chart Total gets cut off on the Dashboard after a resize
Tracks apache/superset#21063.
What Happened
Reported repro (Table chart, SHOW TOTALS enabled, metric format set to
"Original value" so the total renders as a long, unformatted number):
- Create a Table chart with a category dimension and
SUM(metric), totals on, D3 format "Original value". - Save it to a new dashboard.
- In the dashboard editor, shrink the chart's height until the table body shows a vertical scrollbar, then grow it back until the scrollbar goes away and all rows are visible again.
- Save the dashboard.
Expected: the Total row's number is fully visible. Actual: the rightmost part of the total's digits is invisibly clipped off, and stays clipped even though the body no longer needs to scroll.
The PR history on this exact file (#21064 in 2022, #26964 in 2024, #36891 and #42573 earlier in 2026) shows this general area — keeping the sticky header, scrollable body, and sticky footer column widths in sync when a vertical scrollbar appears/disappears — has regressed multiple times under different trigger conditions. This RCA is another iteration on that same mechanism, not a brand new subsystem.
Root Cause
This section has been revised twice after independent review challenged the evidence, each time by doing more real-browser testing rather than defending the previous write-up. This third round retracts a specific claim from round two ("clientWidth=190 vs scrollWidth=194 proves real clipping") after directly falsifying it with hit-testing, and replaces it with a narrower, structurally-guaranteed argument for why the shipped fix is correct.
Retracted, this round — the 190px/194px clientWidth/scrollWidth
comparison from round two is not evidence of clipping, and real hit-testing
shows the content in question stays fully visible.
Round two's central claim was that converging header/footer onto
scrollbar-gutter: stable without also giving them scrollBarStyles
produces a header/footer wrapper clientWidth of 190px against a 194px-wide
<table>, and that this constitutes real clipping. An independent reviewer
challenged this directly: a scrollbar gutter reservation sits inside the
padding edge, and overflow: hidden clips at the padding edge, not
necessarily at the (gutter-reduced) clientWidth. Reproducing the same
205px-wrapper/190px-clientWidth/194px-content geometry in real headed
Chromium and testing it with document.elementFromPoint hit-testing across
every pixel from the wrapper's left edge to well past its right edge — not
just comparing two numbers — confirms the reviewer was right: the content
remains hit-testable (i.e., actually painted, not clipped) all the way to
its own true right edge, and only stops being hit-testable at the
wrapper's real, un-reduced border-box edge (205px), never at the
reduced 190px clientWidth scrollbar-gutter reports. In other words:
scrollbar-gutter: stableon anoverflow: hiddenbox changes whatclientWidthreports, but does not move where the box actually clips content.overflow: hiddenclips at the box's real border-box edge, whether or notscrollbar-gutterhas "reserved" (phantom, sinceoverflow: hiddennever renders a scrollbar to occupy that space) room inside it.
This was checked with a minimal, isolated repro (a plain <div> with the
exact same width, overflow: hidden, scrollbar-gutter: stable, and
::-webkit-scrollbar styling as the header/footer wrapper, containing a
child sized to reproduce the exact 190/194 numbers from round two) so the
result isn't an artifact of react-table or this specific component. It is
a general, verified fact about how Chromium implements overflow: hidden +
scrollbar-gutter, not something specific to this file. This means: the
round-two mechanism — a mismatched scrollbar-gutter reservation between
header/footer and body/sizer — cannot, by itself, cause header/footer to
visibly clip the total row. The scrollBarStyles half of the shipped fix
is not load-bearing for preventing clipping (see below for what it's
actually for).
Verified, this round — a genuinely narrower box, from a real width
subtraction rather than a phantom scrollbar-gutter reservation, does
clip.
This matters because it's exactly what unmodified master does: master's
header/footer compute width: maxWidth - scrollBarSize, where
scrollBarSize comes from the separately JS-measured
getCustomScrollBarSize() probe — a real, literal reduction of the
wrapper's own CSS width, not a CSS-engine-internal reservation. Testing
this class directly (two overflow: hidden boxes, one at the full width,
one built by subtracting a deliberately-too-large amount from that same
width, both containing identically-sized content that fits the full-width
box but not the subtracted one) and hit-testing across the boundary: the
full-width box does not clip the content at all (hit-testable to its own
true edge, same as above), while the subtracted box clips it exactly at its
own — genuinely narrower — border-box edge, with the DOM node's content
completely un-hit-testable beyond that point. Unlike the scrollbar-gutter
case, this clipping is real: the box is actually narrower, not just
reporting a smaller clientWidth while remaining the same size. This
confirms the general mechanism class master relies on (a wrapper narrowed by
maxWidth - <a JS-measured number>) is capable of real clipping if that
JS-measured number is ever larger than what the colgroup was actually sized
against — it's just not the specific scrollbar-gutter-mismatch mechanism
claimed in round two.
Verified, this round — the shipped fix (header/footer always
width: maxWidth, never reduced) cannot be narrower than the <colgroup>
it has to display, in any browser, regardless of what any scrollbar probe
measures. This is a structural argument, not a browser-specific empirical
one:
- The shared
<colgroup>widths come from measuring<th>elements inside the sizer<div>(useSticky.tsx'suseLayoutEffect,ths.map(th => th.getBoundingClientRect()?.width)). - The sizer has no explicit
widthof its own — it's a plain block<div>that takes the width of its containing block (the outermost wrapper, which ismaxWidthwide), then loses more of that to whatever real vertical scrollbar it renders when its content needs to scroll (which is exactly when column widths are being measured forhasVerticalScroll: true). Sosizer.clientWidth <= maxWidth, always, by construction — this isn't contingent on any JS probe agreeing with anything. - The sizer's own
<table>is not giventable-layout: fixed(only header/body/footer's cloned tables get that viamergeStyleProp(table, fixedTableLayout)), so it lays out with the browser's normal automatic table layout, sizing each column from the natural content of all ofthead+tbody+tfoottogether (the sizer renders all three sections at once, unlike header/body/footer which each render only one). That means the measured<th>widths already account for the total row's actual content width whenever that content fits insidesizer.clientWidth— which is the normal, in-scope case this fix targets (see "Latent Bugs Found" for the narrow-chart case where it doesn't fit at all, which is a separate, already-documented issue). - Therefore the widths that end up in
<colgroup>sum to at mostsizer.clientWidth, which is at mostmaxWidth. - Header/footer's own
<table>(with the same<colgroup>, undertable-layout: fixed, with no explicit tablewidth) can only need up to that same colgroup-derived width to render without visually overflowing its own column boxes (browsers still let content wider than its<col>spec expand atable-layout: fixedtable when the table's ownwidthisauto, but the sizer already measured that expanded need, so the colgroup value already includes it — the fixed-layout tables don't need to grow further).
Chaining these: the widest header/footer's own <table> will ever
legitimately need to be is bounded by sizer.clientWidth, which is bounded
by maxWidth. Setting header/footer's wrapper width to the full,
never-reduced maxWidth (what the fix does) therefore can never be
narrower than what the table needs — not "in the one browser tested," but
as a consequence of where each number comes from. This is the actual reason
the fix prevents the reported clipping, and it does not depend on
getCustomScrollBarSize()'s accuracy in any browser, unlike master's
subtraction-based width.
Still unverified/inferred — the exact browser condition that triggers the
bug against unmodified master. This is unchanged from round two and
already accepted by review as a reasonable sandbox limit, so it is only
summarized here: master's width: maxWidth - scrollBarSize is unsafe
exactly when the JS-measured scrollBarSize is larger than whatever real
reservation the sizer/body's own scrollbar actually took (see the previous
paragraph's class-level demonstration for why that direction, specifically,
causes real clipping). Sweeping chart widths (260px→190px) through the full
shrink→grow-back sequence and six zoom levels (0.75×–1.5×) against
unmodified master in real headed Chromium, getCustomScrollBarSize() and
the sizer's real scrollbar reservation agreed at every single step in this
sandbox — so the specific browser/OS/zoom condition that makes them disagree
for the original reporter is not reproducible with the one Linux/Chromium
build and X11 display available here. That the mechanism class itself is
real (previous paragraph) is now on firmer ground than round two's specific
(and now-retracted) numeric claim; that it's master's exact trigger
remains an inference, not a reproduction.
A more likely contributor to the exact reported persistence
("stays clipped after growing back"), inferred but grounded in a separately
verified defect: see the needScrollBar height-comparison bug under
"Latent Bugs Found". It makes hasVerticalScroll switch on too easily and
switch back off only once the chart is grown by an extra
theadHeight + tfootHeight px beyond what's actually needed — i.e. it's
easy for the scrollbar state, and the width reservation that comes with it,
to stay "on" after a grow-back that should have turned it off. Left unfixed
here — a distinct defect, and fixing it would change scrollbar-appearance
behavior more broadly than this PR should.
Why scrollbarGutter/scrollBarStyles are still applied to header/footer,
even though they aren't what prevents the reported clipping (secondary,
less rigorously verified rationale): header/footer aren't purely
decorative viewports — when hasHorizontalScroll is also true, their
<table> is deliberately wider than the wrapper (the same over-wide
<colgroup> as body), and scrollLeft is synced programmatically from body
so header/footer scroll in lockstep as the user scrolls body horizontally
(onScroll in useSticky.tsx). In that combined horizontal-and-vertical
scroll case, body's visible content region is narrower than
maxWidth by whatever its real, physically-rendered scrollbar occupies;
if header/footer's clip viewport is the full, un-reduced maxWidth while
body's real viewport is narrower, the same scrollLeft value would reveal a
wider slice of the row in header/footer than what's actually visible in
body, misaligning columns during horizontal scrolling. Matching
scrollbarGutter/scrollBarStyles on header/footer keeps their reported
clientWidth consistent with body's, which keeps that synced-scroll slice
consistent too. I have not built a real-browser repro of this specific
combined-scroll scenario (the reported bug, #21063, does not describe
horizontal scrolling), so this is presented as a plausible, structurally
motivated reason to keep the change, not a verified fix for a reproduced
defect — unlike the width: maxWidth change above, which is verified.
Sizer/header/footer/body gutter symmetry is conditional, not
unconditional. The sizer's wrapper sets scrollbarGutter: 'stable'
unconditionally (useSticky.tsx, sizer <div> style), while
header/footer/body all set it conditionally —
hasVerticalScroll ? 'stable' : undefined. So the four elements only agree
on gutter reservation while hasVerticalScroll is true; while it's
false, the sizer still reserves a gutter (it's visibility: hidden and
only used for measurement, so this doesn't affect anything visible) while
header/footer/body correctly reserve none, since there's no real scrollbar
to make room for. This is existing, pre-fix behavior, not something this PR
introduces or removes — it's noted here so this document doesn't claim an
unconditional four-way symmetry that isn't actually there.
How this was verified
jsdom (this repo's Jest environment) does not implement real CSS layout —
getBoundingClientRect/clientWidth are stubbed, and hit-testing APIs like
document.elementFromPoint don't reflect real paint/clip behavior — so none
of the claims above can be checked through a jsdom test. This round's
verification used real, on-screen Chromium rendering plus explicit
hit-testing (not just numeric comparisons) for exactly that reason:
- Started
Xvfb :99directly (noxauthavailable in this sandbox, soxvfb-rundidn't work) and pointedDISPLAY=:99atchromium.launch({ headless: false })via Playwright (already cached in this sandbox) — genuine on-screen rendering. - Built minimal, static HTML repros (not part of this diff, not committed)
reproducing the exact box models under dispute: (a) an
overflow: hiddenwrapper withscrollbar-gutter: stableand the same::-webkit-scrollbarstyling as this file'sscrollBarStyles, containing content sized to reproduce round two's exact 190px/194pxclientWidth/content-width numbers; (b) the same comparison with the unstyled (native, wider) scrollbar-gutter reservation, to match header/footer's specific pre-fix state; (c) twooverflow: hiddenwrappers differing only in whether theirwidthis the full value or a value reduced by subtraction, both containing identical over-wide content, to isolate "phantom reservation" from "real narrowing." - For each, measured
clientWidth/getBoundingClientRect()as before, but additionally sweptdocument.elementFromPoint(x, y)across the disputed boundary pixel-by-pixel to determine what is actually hit-testable (i.e., actually painted and not clipped) at each point, rather than inferring clipping from a comparison of two numbers. - Result: the phantom-reservation repros (a) and (b) show content
hit-testable all the way to its own true edge, never clipped at the
reduced
clientWidth; the real-narrowing repro (c) shows content genuinely clipped, exactly at the narrower box's own true edge. This directly falsifies round two's clipping claim while confirming the mechanism class master's subtraction-based width relies on is capable of real clipping when the subtracted amount is too large. - Re-verified the structural
colgroup <= sizer.clientWidth <= maxWidthchain above by inspectinguseSticky.tsxdirectly (this is a logical argument about where each number is computed and from what container, not something that needs a browser to check) rather than re-doing the full chart-width sweep from round two, since round two's sweep already established (and review already accepted) that master's own arithmetic doesn't diverge from the sizer/body's reservation anywhere reproducible in this sandbox — the open question was never "does the fixed code avoid divergence," it's "does removing the subtraction entirely avoid the class of bug regardless of divergence," which the structural argument answers independently of any sweep.
This traces both the retraction and the corrected argument through actual DOM state and hit-testing in a real, on-screen browser, and says plainly where it overturns round two's own conclusion rather than smoothing over it.
Why It Wasn't Caught
There is no test coverage of the sticky header/footer/body width-sync
mechanism at all. Every existing test that renders TableChart or
DataTable explicitly passes sticky={false}
(plugins/plugin-chart-table/test/TableChart.test.tsx), and
plugins/plugin-chart-table/test/DataTable/ only had a test for
getScrollBarSize.ts in isolation — nothing exercises useSticky.tsx's
StickyWrap component, so none of the four fixes to this exact mechanism
since 2022 (#21064, #26964, #36891, #42573) added a regression guard for
either of the two invariants this fix relies on: the load-bearing one
(header/footer's wrapper width must never be reduced below what the
shared colgroup needs — see Root Cause) or the secondary one
(header/footer must report the same clientWidth as body, for consistent
synced horizontal scrolling). Each fix addressed the specific manifestation
someone happened to reproduce, without a test pinning either invariant
itself.
Manual QA also wouldn't reliably catch a width-computation bug of this kind: it would depend on the reporter's specific browser/OS/rendering-mode scrollbar behavior, which is exactly the kind of environment-sensitive condition this investigation had to build custom real-browser tooling (headless and headed, plus hit-testing, not just numeric comparisons) to even observe directly.
The Fix
superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx,
in StickyWrap (~lines 305-354), plus a
/** @jsxImportSource @emotion/react */ pragma added near the top of the
file (see "Prevention" for why):
- Removed the
headerContainerWidth = hasVerticalScroll ? maxWidth - scrollBarSize : maxWidthcomputation and the numericwidth: headerContainerWidthon the header/footer wrapper divs. Replaced with an unconditionalwidth: maxWidth. This is the change that actually prevents the reported clipping: the shared<colgroup>is always bounded bysizer.clientWidth, which is always bounded bymaxWidth(see Root Cause), so a header/footer wrapper that is never narrowed belowmaxWidthcan never be narrower than the table it has to display, regardless of whethergetCustomScrollBarSize()agrees with the sizer/body's real scrollbar reservation in any given browser. - Also added
scrollbarGutter: hasVerticalScroll ? 'stable' : undefinedandcss={scrollBarStyles}(the same custom::-webkit-scrollbarstyling the body/sizer already carry) to the header/footer wrapper divs. This step is not required to prevent the reported clipping — real hit-testing showsscrollbar-gutteron anoverflow: hiddenbox changes whatclientWidthreports without moving where content actually clips (see Root Cause) — but it is kept for a secondary, less rigorously verified reason: keeping header/footer's reportedclientWidthconsistent with body's keeps their syncedscrollLeftviewport consistent with body's real, physically-scrollbar-reduced viewport when both horizontal and vertical scrolling are active simultaneously.
getCustomScrollBarSize() is untouched and still used for the separate,
legitimate horizontal-scrollbar real-height reservation elsewhere in the
same effect.
This removes the divergent, subtraction-based computation rather than
compensating for it (e.g. no new "add a few px of buffer" fudge factor),
because a buffer would itself be a browser/OS/zoom-dependent number with the
exact same class of bug, and because the un-reduced width: maxWidth is
already provably wide enough without needing to guess at a safety margin.
Alternatives Considered
- Keep
getCustomScrollBarSize()for header/footer, but also apply it to the body/sizer instead ofscrollbar-gutter. Rejected: this is the inverse of the chosen fix and has the same property of relying on a JS probe matching a real scrollbar's occupied space, rather than reusing one first-class CSS mechanism (scrollbar-gutter) everywhere. It would also touch the sizer's mounted-content measurement path, a larger and riskier change for a bug that doesn't require it. - Add a numeric buffer/fudge factor to
headerContainerWidth(e.g. round up, or add a few px of slack). Rejected per the task's own guidance: a fudge factor is itself a browser/OS/zoom-dependent number and doesn't remove the underlying defect, just narrows the range where it manifests. - Fix the
needScrollBarheight-comparison bug instead/also (see Latent Bugs). Considered, because it's a plausible contributor to the "stays clipped after growing back" persistence. Rejected for this PR: it's a different defect (scrollbar visibility logic, not the header/footer wrapper width logic this fix targets), fixing it changes when scrollbars appear/disappear more broadly than this bug fix should, and the independent review already agreed it should stay out of scope. - Drop
scrollbarGutter/scrollBarStylesfrom header/footer, keeping only the unconditionalwidth: maxWidthchange. Considered once real hit-testing showed these aren't what prevents the reported clipping (see Root Cause). Kept anyway: while unverified against a reproduced defect, they're not free of purpose either — dropping them would let header/footer report a widerclientWidththan body during simultaneous horizontal and vertical scrolling, which is plausibly a real (if different) column misalignment risk during synced horizontal scroll. Removing them, on the evidence available here, would trade a proven-unnecessary line of CSS for a plausible new alignment gap, which isn't a clear improvement.
Latent Bugs Found
needScrollBarheight comparison in the same file'suseLayoutEffect(useSticky.tsx~lines 199-205): it comparesinnerHeight: fullTableHeight(thead + tbody + tfoot) againstheight: maxHeight - theadHeight - tfootHeight(the body-only budget). The correct comparison isfullTableHeight > maxHeight(equivalently,tbodyHeight > maxHeight - theadHeight - tfootHeight); the current form double-counts the header/footer height, sohasVerticalScrollreportstrueup totheadHeight + tfootHeightpx earlier than actually necessary, and only reverts tofalseonce the chart is grown well past the point a user would expect. Verified by direct computation and by a real-browser measurement showing a 400px container reportinghasVerticalScroll: truefor content that only needed 379px. Plausibly a meaningful contributor to why "grow the chart back up" doesn't always self-heal a scrollbar-adjacent layout issue (see Root Cause). Left unfixed — a different defect than the header/footer wrapper-width bug this PR fixes, and changing it alters scrollbar-appearance behavior more broadly than this fix should.- At chart widths where the Total's unbreakable natural content width
(numbers have no break points, so
table-layout: autocannot wrap them) is only barely smaller than the chart's total width, the total clips identically on master and on the fixed code, at every resize step, including before any scrollbar ever appears. Verified via the same real-browser sweep (both headless and headed Chromium). This is a different, more fundamental "the chart is too narrow for this content regardless of scrollbars" case, not the header/footer wrapper-width bug this PR targets — not fixed here. - The
<col width={w} />values passed to<colgroup>are floats (e.g.95.65625). The legacy HTMLwidthattribute on<col>may be rounded down/truncated by some rendering engines, which could compound with either of the above. Not confirmed to matter on evergreen Chromium in this investigation; noted for anyone touching this file again.
Prevention
Added plugins/plugin-chart-table/test/DataTable/hooks/useSticky.test.tsx,
which mounts useSticky's StickyWrap for real (via react-table) with
mocked DOM measurements that force hasVerticalScroll: true, and mocks
getCustomScrollBarSize() to return a value distinguishable from any
CSS-computed one. It asserts, pinning the two invariants separately rather
than treating them as one:
- Load-bearing. The header/footer wrapper's
style.widthmatches the body's (maxWidth, not any pixel value) — pinning "header/footer's width is never reduced below what the shared colgroup needs." Fails against the pre-fix code (258pxvs. expected300px, since300 - 42 (mocked probe) = 258, a header/footer wrapper genuinely narrower than the colgroup it has to display) and passes after the fix. - Secondary. The header/footer wrapper's
style.scrollbarGutterandclassName(populated via thecss={scrollBarStyles}prop) both match the body's — pinning "header/footer report the sameclientWidthas body," which matters for keeping their synced horizontalscrollLeftconsistent with body's real, scrollbar-reduced viewport (see Root Cause), not for preventing the reported clipping by itself.
The className assertion needed one more thing to be observable at all:
useSticky.tsx now carries a /** @jsxImportSource @emotion/react */
pragma (added just below the license header). Without it, this repo's
Jest/Babel setup passes the css prop straight through to the DOM as an
inert string attribute (css="[object Object]", confirmed empirically —
headerDiv.getAttribute('css') returned exactly that before the pragma was
added) instead of resolving it through Emotion's JSX runtime into a real
class, because — unlike the webpack/SWC production build, which sets
importSource: '@emotion/react' globally for every file
(webpack.config.js, createSwcLoader) — this repo's babel.config.js
does not set the equivalent importSource for @babel/preset-react in its
Jest (test) env, and no file in this codebase currently carries the
per-file pragma that would substitute for it. Adding the pragma to this one
file makes Babel treat it exactly the way SWC already treats every file in
the real build (both resolve to the same @emotion/react JSX runtime): the
per-file pragma specifies the identical import source SWC already applies
globally, so it changes nothing about the shipped, webpack-built behavior —
it only changes what Jest's separate Babel pipeline can observe. This test
fails on the commit before
css={scrollBarStyles} was added to header/footer (header/footer
className is '' while body's is populated) and passes on the fix
(verified by temporarily reverting just the two css={scrollBarStyles}
additions and re-running the test, then restoring them).