Skip to content

fix(styled): styling props no longer reach the DOM — 0.77.7 - #697

Merged
davidnich merged 6 commits into
developfrom
bugfix/dom-prop-leaks
Oct 7, 2026
Merged

davidnich merged 6 commits into
developfrom
bugfix/dom-prop-leaks

Conversation

@davidnich

@davidnich davidnich commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Problem

Reqore's own styling props reached DOM elements. React warned in consumers' story runs and tests, and some props were written out as attributes:

  • <DatePicker value={new Date()} onChange={…} /> on its own warned about clearable ("Received true for a non-boolean attribute") and hasIcon, hasRightIcon, hasShortcutHint, hasPasswordToggle ("React does not recognize the prop"). With minimal or pill set, it warned about those too, and wrote theme="[object Object]", effect, _size and value onto the segment group's <div>.
  • <ReqoreTabs fill …/>, <ReqoreCollection fill …/>, and a ReqoreCheckbox given fill (a control group hands fill to every child) warned about fill.
  • Buttons, tags and links rendered a color attribute. Spacers and table cells rendered width and height. Panel titles rendered opacity, and timelines rendered direction.

Root cause

There are two separate gaps in styled-components v5:

  1. A styled DOM tag filters props with is-prop-valid. That filter keeps any name that is an attribute on some element, so fill, wrap, color, width, height, opacity, direction, offset and type all reach a div or span.
  2. A styled component rendered as another component forwards every prop. That component then writes the props onto its own element. This applies to react-aria's DateInput, re-resizable, animated.div, radix's slider root and a consumer's router link. The bare DatePicker hit this case: ReqoreInput renders its styled input as={DateInput}, and react-aria's Group spreads everything it is given onto its <div>.

Fix

The fix uses the repo's existing convention, omitStyleProps with .withConfig({ shouldForwardProp }). Each styled component now names the styling props it keeps back. Prop names are unchanged and styles still read every prop, so only the rendered element changes.

  • StyledEffect keeps back color, effect, intent, isText and theme. Every component built on it inherits this, because styled-components combines a base's shouldForwardProp with the derived one.
  • REQORE_CONTROL_GROUP_CHILD_PROPS (in helpers/styled.ts) is one list of every prop ReqoreControlGroup clones onto its children: the layout flags, plus the stack-position props in a stack group. ReqoreControlGroupItem and the slider root use it, and a test checks the list against what the group actually hands out.
  • Per component:
    • Input (styled input, wrapper, icon/shortcut wrappers)
    • DatePicker (DateInput no longer gets the field's value)
    • Button (+ content, label spans)
    • Tabs (tabs, list, item)
    • Collection (wrapper, item content)
    • Checkbox (row, switch toggle)
    • Menu (resizable Resizable target, divider)
    • Panel (resizable target, title bars)
    • Notification/Message (animated.div target)
    • Tag (+ action wrapper)
    • Span/Link (router-link target)
    • Paragraph
    • Icon
    • Spacer
    • Timeline
    • Navbar (type)
    • Table (cell, column group)
    • Slider root
  • ReqoreTooltipComponent no longer passes tooltip to a DOM element (Component='div').
  • The popover no longer clones closePopover onto content that is a DOM element. A ReqoreMessage in popover content also no longer forwards it.
  • ReqoreTree takes its unused mode prop out of what it spreads onto the panel.

Sweep

I rendered every story (73 files, 1092 stories) in jsdom. For every element, I read the props React actually received (React 18 keeps them on the node). I did not rely on React's warnings, which come only once per prop name per page. I also ran the whole unit suite with the same check after each test, and ran every story file in the browser (yarn test:stories, one file at a time) with the plays opening popovers, menus and dropdowns.

Leaks found and fixed: all of the components listed above. On develop, the browser story run logged 26 DOM-prop warnings. On this branch, the only one left is a story's own misuse: TagGroup.stories passes tag props (onRemoveClick, rightIcon, actions) to the group in meta.args.

Found and not changed:

  • disabled and readOnly on non-form elements (.reqore-control-wrapper, .reqore-checkbox, tags, table cells, panels, …). These reflect state, and code queries them; reqore's own IconPicker test asserts disabled on .reqore-icon-picker. Changing them would change behaviour, so it is left for a separate decision.
  • zindex="-1" on the rich-text editor comes from slate-react itself.
  • placeholder on the Dropdown button.

Two bugs the sweep found, fixed in follow-up commits

ReqorePanel given as rendered a Resizable (2f373f3)

  • Cause: as={rest.as || cond ? Resizable : 'div'} binds as (rest.as || cond) ? Resizable : 'div'. It was as={rest.as || Resizable} until 9ad9ed4 added the div fallback without parentheses, so the caller's as was meant to win.
  • Fix: the caller's as now wins. re-resizable's props are spread only when the panel really renders a Resizable, so they no longer land on the caller's element.
  • Other sites: a TypeScript-AST scan of every a || b ? c : d in src (127 of them) found no other one with a fallback value on the left of the ||.

ReqoreTier rendered its badge without a size (c9df9db)

  • Cause: ButtonBadge sizes the gap in front of it from PADDING_FROM_SIZE[size], which was NaN. The spacer then ended up neither horizontal nor vertical and drew nothing.
  • Fix: the tier's badge sits on a line of its own, so it now asks for no gap (margin='none', like the Callout, EntityRow and FeatureCard badges) and passes the panel's size.
  • Default: ButtonBadge now defaults size to normal, which is the size its tag already fell back to, so a badge without a size can no longer produce NaN.
  • Other callers: every other ButtonBadge caller already passes a size.

Tests: __tests__/panelAs.test.tsx checks that an element or component given as as renders, that as wins over resizable, that a resizable panel still renders a Resizable, and that a plain panel renders a div. __tests__/buttonBadgeSize.test.tsx checks that a badge without a size gets a 4px gap and that a Tier draws no unsized spacer. All of these fail on develop except the two "still renders" checks. The full suite now passes 99 files and 1230 tests.

Visual: I captured every story again on this branch. Tier and Panel are byte-identical to develop: the tier's old spacer drew nothing, and no story gives a panel as. The remaining differences against develop are the same run-to-run flakes as before. The ones that did not match an earlier capture (Icon/Basic, Testimonial/With Avatar Image) were byte-identical to develop on a re-run, and Paging/Infinite differs between runs of the same tree.

Tests

  • __tests__/domPropLeaks.test.tsx renders every component that leaked, using the props that leaked, and asserts two things: no non-DOM prop reached any element, and React logged no DOM-prop warning. It covers 20 cases, plus the control-group list check and two self-checks of the detector. On develop, all 19 leak cases and the list check fail. On this branch, all pass.
  • Full unit suite: 99 files and 1230 tests pass. Lint and build:test:prod are clean (the pre-push hook ran them).
  • Every story file passes in the browser.

Visual

I captured every story with Qlip locally on develop and on this branch. 1049 of 1077 screenshots are byte-identical. I re-ran the 28 that differed on both trees: each one also changes between runs of the same tree (caret, hover/transition and load-timing states), and the Error Boundary story shows the local checkout path. No pixel change comes from this PR.

Version: 0.77.7 (one bump for the PR) (0.77.6 is taken by #696).

🤖 Generated with Claude Code

React warned about Reqore's own styling props on DOM elements ("React
does not recognize the `hasIcon` prop", "Received `true` for a
non-boolean attribute `fill`"), and some were written out as attributes
(`theme="[object Object]"`, `color="#454545"` on buttons).

Two gaps let them through. A styled DOM tag filters props with
is-prop-valid, which keeps every name that is an attribute somewhere:
`fill`, `wrap`, `color`, `width`, `opacity`, `direction`, `offset`, ...
And a styled component rendered `as` another component (react-aria's
DateInput, re-resizable, animated.div, radix, a router link) forwards
everything, and that component writes it onto its own element. The
bare DatePicker hit the second: ReqoreInput handed its styled input's
flags to DateInput, whose Group put them on its div.

The styled components now name the styling props they keep back, with
the existing omitStyleProps helper. StyledEffect keeps back `color`,
`effect`, `intent`, `isText` and `theme`, so every component built on
it inherits that. The props ReqoreControlGroup hands its children are
one list, REQORE_CONTROL_GROUP_CHILD_PROPS, checked against what the
group actually hands out. ReqoreTooltipComponent no longer passes
`tooltip` to a DOM element, and ReqoreTree drops its unused `mode`.

Every story was rendered and the props each element received were
checked; the test renders the components that leaked, reading what
React handed each element rather than relying on React's warnings,
which come once per prop name per page.
…ePopover

The popover clones `closePopover` onto every element of its content so a
menu or a panel inside it can close it. A DOM element cannot use it, and
React warned about it; a `ReqoreMessage` in the content forwarded it
through `animated.div` onto its `<div>`. Found by the browser run of the
Popover stories, whose plays open the popovers.
@davidnich
davidnich requested a review from Foxhoundn as a code owner October 6, 2026 19:49
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

📦 Prerelease published for this PR:

@qoretechnologies/reqore@0.77.7-pr.697.g7a79650

Install it in a consumer (updates package.json + lockfile):

yarn add @qoretechnologies/reqore@0.77.7-pr.697.g7a79650

Published under the pr dist-tag — latest/beta are untouched. Repoint consumers to the released version once this PR merges.

@qlip-qore-technologies

qlip-qore-technologies Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

qlip visual regression

✅ No visual changes.

Review build 20261007-063431 in the dashboard →

`as={rest.as || resizable ? Resizable : 'div'}` binds as
`(rest.as || resizable) ? Resizable : 'div'`, so a panel given `as`
rendered re-resizable's `Resizable` and the caller's element never
appeared. It was `as={rest.as || Resizable}` until 9ad9ed4 added the
`div` fallback without parentheses; the caller's `as` was meant to win.

`as` now wins, and re-resizable's props are only spread when the panel
really renders a `Resizable`, so they do not land on the caller's
element either. No other `a || b ? c : d` in src has a fallback value
on the left of the `||`.
ReqoreTier rendered `<ButtonBadge content={badge} />` with no size, and
the badge sized the gap in front of it from `PADDING_FROM_SIZE[size]`:
`NaN`, so the spacer was neither horizontal nor vertical and drew
nothing. The badge sits on a line of its own, so the tier now asks for
no gap (`margin='none'`, like the callout, entity row and feature card
badges) and passes the panel's size. ButtonBadge itself defaults `size`
to `normal`, the size its tag already fell back to, so a badge without
one can no longer compute `NaN`.
… props object

DeepScan REACT_USELESS_DEPENDENCY_OF_HOOK: renderTag listed the per-render
props object as its dependency, so it was rebuilt on every render; it only
reads its arguments and `active`. The badge list was also a fresh array
whenever the content was not one, which defeated the three badge-position
memos; it is now memoised on the content itself.
@davidnich
davidnich merged commit e43e24f into develop Oct 7, 2026
7 checks passed
@davidnich
davidnich deleted the bugfix/dom-prop-leaks branch October 7, 2026 06:52
Foxhoundn added a commit that referenced this pull request Oct 7, 2026
…nobs

Brings in #697 (styling props no longer reach the DOM), released as
0.77.7 while this PR was open. The only conflict was the version: this
PR moves from 0.77.6 to 0.77.8, the next one above develop's 0.77.7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Foxhoundn added a commit that referenced this pull request Oct 7, 2026
develop released 0.77.7 (#697) while this PR was open, so these knobs and
the button / input font fix ship in 0.77.8, not 0.77.6. The "new in" and
"changed in" notes say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant