Skip to content

[utils] Add opt-in DataAttributesOverrides augmentation for slot props#48554

Merged
LukasTy merged 12 commits into
mui:masterfrom
LukasTy:claude/data-attributes-overrides
Jun 25, 2026
Merged

[utils] Add opt-in DataAttributesOverrides augmentation for slot props#48554
LukasTy merged 12 commits into
mui:masterfrom
LukasTy:claude/data-attributes-overrides

Conversation

@LukasTy

@LukasTy LukasTy commented May 20, 2026

Copy link
Copy Markdown
Member

Created based on mui/mui-x#22128 exploration.

Closes #33175.

Summary

Adds a single opt-in switch — DataAttributesOverrides — that lets consumers declare typed support for data-* attributes on every MUI slot prop. Augmenting the interface is the one sanctioned way to flip the level of strictness; nothing is widened by default.

Today, code like

<Backdrop slotProps={{ root: { 'data-testid': 'backdrop' } }} />

is a TypeScript error even though the attribute is forwarded to the DOM at runtime. After this PR, consumers can opt in with a one-time augmentation:

declare module '@mui/utils/types' {
  interface DataAttributesOverrides {
    'data-testid'?: string;
  }
}

Or, for the loose / "anything goes" form:

declare module '@mui/utils/types' {
  interface DataAttributesOverrides {
    [k: `data-${string}`]: string | number | boolean | undefined;
  }
}

After augmentation, every Material component that wires slot props through SlotComponentProps / SlotComponentPropsWithSlotState (or through SlotProps in @mui/material) picks up the augmented keys automatically.

Changes

@mui/utils/types

  • New DataAttributes.ts:
    • DataAttributesOverrides — empty, module-augmentable interface. The single switch consumers flip.
    • DataAttributes = DataAttributesOverrides — dormant by default; activates when augmented.
    • WithDataAttributes<T> = T | (T & DataAttributes) — union form so the original T stays assignable as-is (preserves backwards compatibility with x as CustomProps style casts on slot values), while augmented keys flow through the widened branch when consumers opt in.
  • index.ts re-exports the new symbols, and wraps both SlotComponentProps and SlotComponentPropsWithSlotState (object branch and callback branch) with WithDataAttributes.

Module-augmentation test

packages/mui-material/test/typescript/moduleAugmentation/dataAttributesOverrides.{spec.tsx,tsconfig.json} augments @mui/utils/types and uses Backdrop's root slot to verify the augmentation flows through SlotPropsSlotComponentPropsWithDataAttributes.

Notes for reviewers

Why a union, not an intersection

export type SlotComponentProps<...> =
  | WithDataAttributes<Partial<React.ComponentPropsWithRef<TSlotComponent>> & TOverrides>
  | ((ownerState: TOwnerState) =>
      WithDataAttributes<Partial<React.ComponentPropsWithRef<TSlotComponent>> & TOverrides>);

export type WithDataAttributes<T> = T | (T & DataAttributes);

Only the widened variant carries DataAttributes. This preserves backwards compatibility:

  • { id: 'foo' } as CustomLabelProps assigns to the narrow variant, so CustomLabelProps does not need to declare a data-${string} index signature.
  • { 'data-testid': 'x' } (after augmentation) assigns to the widened variant.
  • The callback branch also uses WithDataAttributes, so consumers returning someObj as CustomProps from a slot callback stay assignable.

Why opt-in (no default widening)

Slot prop types should not silently accept arbitrary data-* keys — that hides typos, makes the surface less discoverable in hover/autocomplete, and disagrees with the principle that React's typed surface is what consumers see.

The augmentation hook is a single, well-known module path (@mui/utils/types) that consumers can shape to whichever level they want: strict (one named key), loose (full data-* template-literal index signature), or anywhere in between.

Why @mui/utils/types is the right home

Every MUI slot prop type ultimately flows through SlotComponentProps / SlotComponentPropsWithSlotState in @mui/utils/types. Putting the augmentation hook here means:

  • A consumer's single augmentation lights up every Material component's slots automatically.
  • Downstream packages (@mui/x-data-grid, @mui/x-date-pickers, @mui/x-charts, ...) inherit the augmentation transitively — they don't need to mirror the helper themselves.
  • The contract is documented in one place.

Test plan

  • pnpm --filter "@mui/utils" run typescript passes.
  • pnpm --filter "@mui/material" run typescript passes.
  • pnpm typescript:module-augmentation — all 21+ existing tests pass, plus the new one.
  • pnpm prettier --check and pnpm eslint clean on the changed files.
  • Manual: with the four-line DataAttributesOverrides augmentation above, <Backdrop slotProps={{ root: { 'data-testid': 'foo' } }} /> type-checks and forwards to the DOM as expected. Without the augmentation, the same code is a TS error (matches today).

Today, passing `data-testid` (or any `data-*` attribute) through `slotProps`
on a MUI component is a TypeScript error even though the attribute is
forwarded to the DOM at runtime. This adds a single, opt-in switch that
lets consumers declare exactly which `data-*` keys they want typed, at
whichever level of strictness they choose.

- `DataAttributesOverrides`: module-augmentable empty interface in
  `@mui/utils/types`. The single sanctioned switch.
- `DataAttributes = DataAttributesOverrides`: dormant by default;
  populated only when a consumer augments.
- `WithDataAttributes<T> = T | (T & DataAttributes)`: union form so the
  original `T` stays assignable as-is (preserves backwards compatibility
  with `x as CustomProps` style casts on slot values), while augmented
  keys flow through the widened branch when consumers opt in.
- `SlotComponentProps` and `SlotComponentPropsWithSlotState` now wrap
  their object and callback branches with `WithDataAttributes`. With an
  empty default the wrapping is a no-op until a consumer augments — once
  they do, every Material component that reaches slot props through these
  helpers (or through `SlotProps` in `@mui/material`) picks up the new
  keys automatically.

Consumers opt in with a single `declare module '@mui/utils/types' { ... }`
block. Examples are documented in the new `DataAttributes.ts` file and
exercised by a module-augmentation test using Backdrop's root slot.

This is the canonical place for the helper because every MUI slot prop
type ultimately flows through `@mui/utils/types`; downstream packages
(`@mui/x-*` and friends) get the augmentation transitively without
having to mirror the helper themselves.
@code-infra-dashboard

code-infra-dashboard Bot commented May 20, 2026

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/material 0B(0.00%) 0B(0.00%)
@mui/lab 0B(0.00%) 0B(0.00%)
@mui/private-theming 0B(0.00%) 0B(0.00%)
@mui/system 0B(0.00%) 0B(0.00%)
@mui/utils 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@zannager zannager added the package: utils Specific to the utils package. label May 20, 2026
@zannager
zannager requested a review from mnajdova May 20, 2026 13:23
Comment thread packages/mui-utils/src/types/DataAttributes.ts Outdated
Comment thread packages/mui-utils/src/types/DataAttributes.ts Outdated
@LukasTy LukasTy added the type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. label May 20, 2026
@mnajdova

Copy link
Copy Markdown
Member

Or, for the loose / "anything goes" form:

declare module '@mui/utils/types' {
  interface DataAttributesOverrides {
    [k: `data-${string}`]: string | number | boolean | undefined;
  }
}

I would go with this option if we do decide to do something about it, as it reflects the primitives elements. Can you link some issues related to the problem? What are people typically complaining about.

@LukasTy LukasTy self-assigned this Jun 3, 2026
@LukasTy

LukasTy commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Can you link some issues related to the problem? What are people typically complaining about.
Thanks for taking a look. Issue links and a summary of what people hit below.

Umbrella issues

More specific reports (same root cause)

What people are actually complaining about

  1. Testing locators, overwhelmingly. Almost every report is someone trying to attach data-testid / data-cy / data-test-id for Playwright, Cypress, or Testing Library and getting a TS error.
  2. It is purely a typings problem. The attributes are forwarded to the DOM at runtime today; only the compile-time check rejects them. [textfield] Typings for formHelperText slot missing data attributes #47230 says this outright ("this is purely a typings issue. At run-time it works perfectly").
  3. The inconsistency is the sharpest pain. The same component accepts data-* on one slot and rejects it on another (TextField htmlInput vs formHelperText, Checkbox inputProps, Badge badge slot), so people cannot predict where the cast is needed.
  4. The current workarounds are unsatisfying. Either a global declare module 'react' augmentation of HTMLAttributes (too broad, leaks everywhere), or as any at each call site. The top-voted comment on [core] Allow data-* attributes on all *props  #33175 is exactly the global-augmentation hack, and Bump @types/uuid from 8.0.0 to 8.0.1 #22126 shows the hand-rolled CustomInputProps extends React.InputHTMLAttributes pattern.

On the "anything goes" form

Agreed, and the opt-in design already supports it as the headline example:

declare module '@mui/utils/types' {
  interface DataAttributesOverrides {
    [k: `data-${string}`]: string | number | boolean | undefined;
  }
}

That single augmentation lights up every slot of every Material (and downstream X) component at once, which directly answers the "I just want data-* everywhere, like the primitive elements" use case from #33175 and #22126. The strongly-typed form (one named key) stays available for teams that want a closed allow-list, but I am happy to make the index-signature form the primary documented example since it matches what most of these reports are asking for.

One drawback of this approach is no IntelliSense. With specific typing, users would get autocompletion for defined fields.
I think we should mention this and have both examples in docs.

LukasTy added 3 commits June 3, 2026 16:42
- DataAttributes.ts: convert the augmentation examples to JSDoc `@example`
  tags so they surface in IDE quick-info. Lead with the loose
  index-signature form, which mirrors the primitive elements and matches
  what most reports ask for.
- dataAttributesOverrides.spec.tsx: switch the primary spec to the loose
  `[k: \`data-${string}\`]` form and exercise both the object and callback
  branches of the slot-prop union.
- dataAttributesOverridesAllowList.spec.tsx (new): cover the strict,
  closed-allow-list form, with a `@ts-expect-error` proving an undeclared
  `data-*` key is still rejected.
- DataAttributes.ts: note in the JSDoc that the loose index-signature form
  accepts any `data-*` key but gives no autocomplete, while the
  strongly-typed form restricts to declared keys in exchange for
  IntelliSense and typo-checking.
- Cross-reference the loose and narrow module-augmentation specs so the
  pair is discoverable from either file, and spell out why they must live
  in separate compilation units (augmentation is global, so the loose
  index signature would otherwise swallow the narrow allow-list).
Replace the dense, semicolon-spliced tradeoff sentence and the
triple-clause @example labels with a scannable two-bullet "which form to
pick" list. The bullets carry the loose-vs-explicit tradeoff (autocomplete
and typo-checking vs accept-anything), so the @example comments shrink to
plain labels.
@LukasTy
LukasTy requested a review from mj12albert June 4, 2026 14:34

@siriwatknp siriwatknp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice opt-in design.

The union WithDataAttributes<T> = T | (T & DataAttributes) instead of a bare intersection is the right call. It keeps pre-typed as CustomProps slot values assignable without forcing a data-* index signature.

I tested the four core behaviors against the repo tsc (loose form, strict allow-list, un-augmented default still errors, as cast stays assignable) and all hold.

No blockers from me. Two small nitpicks below.

Comment thread packages/mui-utils/src/types/index.ts Outdated
LukasTy added 2 commits June 17, 2026 15:29
… internal

Two nitpicks from review:

- Add a non-augmenting module-augmentation fixture
  (dataAttributesOverridesDefault) that asserts, via @ts-expect-error, that
  a data-* key is rejected on a slot prop when DataAttributesOverrides is NOT
  augmented. The existing loose/allow-list fixtures both augment, so nothing
  guarded the opt-in default; this locks the no-default-widening contract.
  Verified non-vacuous: temporarily widening the default base interface makes
  the directive unused and fails the compile (TS2578).

- Re-export only DataAttributesOverrides and WithDataAttributes by name from
  types/index.ts instead of `export *`. DataAttributes is a bare pass-through
  alias of DataAttributesOverrides with no behavior, so it is now an internal
  (non-exported) type rather than public surface. Confirmed against the built
  types/index.d.ts: only the two intended symbols are exported, and the
  `./types/*` deep path is not exposed.
After the opt-in redesign, `DataAttributes` had collapsed to a bare
`= DataAttributesOverrides` pass-through with no behavior. Drop it and have
`WithDataAttributes` intersect `DataAttributesOverrides` directly, which is
also the idiomatic MUI pattern (augmentable `*Overrides` interfaces are meant
to be intersected into types). Behavior is unchanged: all three module-
augmentation fixtures (loose, allow-list, un-augmented default) still pass.
@LukasTy
LukasTy requested a review from siriwatknp June 17, 2026 13:24
@LukasTy

LukasTy commented Jun 23, 2026

Copy link
Copy Markdown
Member Author

WDYT @siriwatknp, should we include a docs example about using this "feature"?

EDIT: I've added https://deploy-preview-48554--material-ui.netlify.app/material-ui/guides/typescript/#allowing-data-attributes-on-slotprops.
Let me know what you think. 🙏

Add a "Allowing data-* attributes on slotProps" section to the Material UI
TypeScript guide, right after the "strict by default, loose via opt-in" note
it exemplifies. Covers the loose index-signature form and the strict
allow-list form, with the augmentation target (@mui/utils/types) spelled out.
Makes the opt-in discoverable for users hitting the long-standing data-testid
typing error (mui#33175).
Comment thread docs/data/material/guides/typescript/typescript.md Outdated
Comment thread docs/data/material/guides/typescript/typescript.md Outdated
LukasTy and others added 2 commits June 24, 2026 12:37
Surface `DataAttributesOverrides` (and `WithDataAttributes`) from
`@mui/material/utils` so Material users augment a Material-native path
instead of `@mui/utils/types`. It joins the existing
`export type { SlotComponentProps, ... } from '@mui/utils/types'`
re-export block, and `@mui/material/utils` already hosts the runtime
counterpart (`mergeSlotProps`), so it is the natural home.

Augmenting a re-export merges into the original interface, so
`declare module '@mui/material/utils'` reaches the same
`DataAttributesOverrides` that the slot-prop helpers read -- verified by
a new module-augmentation fixture and a real-package probe. The
`@mui/utils/types` path stays valid and canonical; this is additive.

- Re-export the two symbols from packages/mui-material/src/utils/types.ts.
- Repoint the TypeScript guide section to `@mui/material/utils` and
  simplify the intro sentence (review feedback).
- Add a Material-path module-augmentation fixture alongside the
  @mui/utils/types ones.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Replace the empty `import type {} from '@mui/material/utils'` (only there to
pull the module into the program for source resolution) with a real import of
`WithDataAttributes`, exercised on a custom slot-props type. This drops the
redundant-looking line and adds coverage that the re-exported helper is usable
from `@mui/material/utils`.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@LukasTy
LukasTy merged commit 48a4f98 into mui:master Jun 25, 2026
22 checks passed
@LukasTy
LukasTy deleted the claude/data-attributes-overrides branch June 25, 2026 12:01
// See `dataAttributesOverridesAllowList.spec.tsx` for the narrow counterpart
// (a closed allow-list with autocomplete). The two variants live in separate
// files because module augmentation is global per compilation unit.
declare module '@mui/utils/types' {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be declare module ‘@mui/material/utils’ {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's intentional.
Some tests check that the original path works, other asserts the friendlier re-export path also does the job. 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: utils Specific to the utils package. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[core] Allow data-* attributes on all *props

5 participants