Skip to content

fix(react): skip undefined props in attachProps - #31345

Open
ptmkenny wants to merge 5 commits into
ionic-team:mainfrom
ptmkenny:react-attach-props-undefined
Open

fix(react): skip undefined props in attachProps#31345
ptmkenny wants to merge 5 commits into
ionic-team:mainfrom
ptmkenny:react-attach-props-undefined

Conversation

@ptmkenny

@ptmkenny ptmkenny commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Issue number: resolves #31344

V9 version: #31349


What is the current behavior?

When a React component forwards an optional prop to an Ionic component and the caller
leaves it unset, @ionic/react writes the string "undefined" into the corresponding DOM
attribute.

const MyToggle: React.FC<{ id?: string }> = ({ id }) => <IonToggle id={id}>Toggle</IonToggle>;

<MyToggle />

renders:

<ion-toggle id="undefined" role="switch" aria-checked="false" aria-labelledby="ion-tg-0-lbl" tabindex="0" class="md toggle-label-placement-start toggle-ltr hydrated">Toggle</ion-toggle>

There is no error and no warning. Consequences:

  • Every element rendered this way carries the same id, so any page with more than one has
    duplicate ids (invalid HTML), and document.getElementById('undefined') resolves to
    whichever comes first.
  • id is not special. Any prop backed by a reflected DOM property behaves the same way:
    title={undefined} produces a tooltip that reads "undefined", and slot={undefined}
    places the element in a slot named undefined, which moves it in the layout.
  • A prop that had a value and is then set to undefined is not cleared: the attribute is
    overwritten with "undefined" rather than removed.

What is the new behavior?

Skip undefined values, and treat a prop that had a value and no longer does
as a removal, which is how React handles it.

Does this introduce a breaking change?

  • Yes
  • No

Other information

Prepared with Claude Opus. This seems to be @ionic/react specific, as attachProps is React-only.

@ptmkenny
ptmkenny requested a review from a team as a code owner August 11, 2026 08:24
@ptmkenny
ptmkenny requested a review from BenOsodrac August 11, 2026 08:24
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

@ptmkenny is attempting to deploy a commit to the Ionic Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the package: react @ionic/react package label Aug 11, 2026
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ionic-framework Ready Ready Preview Aug 11, 2026 5:03pm

Request Review

@ShaneK ShaneK changed the title @ionic/react: attachProps should drop undefined fix(react): skip undefined props in attachProps Aug 11, 2026

@ShaneK ShaneK 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.

Thanks for bringing this to our attention and taking a shot at this! I left some feedback, most of which needs action before we can move forward with this

Also I modified the title to conform to our PR naming standards

(node as any)[name] = newProps[name];
const propType = typeof newProps[name];
const value = newProps[name];
if (value === undefined) {

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.

Passing null hits this same path. On this branch { id: null, title: null, slot: null } comes out as id="null" title="null" slot="null", and going from 'x' to null leaves id="null" instead of clearing. Same mechanism, since typeof null is 'object' so it skips the setAttribute branch but the property write still stringifies.

Since this is the same bug and v8 is still the current major, I think it's worth covering here rather than leaving it for a follow-up. The catch is that the obvious value == null widening would be a regression: null is a real value for some props (ion-input declares value?: string | number | null) and setting the property to null is how a controlled IonInput gets cleared. So it needs to be narrowed to the natively reflected properties.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, it makes sense to handle null too. I've updated the PR.

*/
if (oldProps[name] !== undefined) {
(node as any)[name] = undefined;
node.removeAttribute(camelToDashCase(name));

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.

There are two attribute names in play for a camelCase prop, and this only clears one of them. The render() path emits access-key="k" because it dash-cases the prop name, while the property write here reflects to accesskey="k", so both end up on the element. On removal camelToDashCase gives access-key, which React has already dropped anyway, and the reflected accesskey="undefined" stays behind. Same for tabIndex, which leaves a stale tabindex="0". Dash-cased props are fine, myProp clears correctly.

Not a regression, since the old unconditional property write produced the same residue more often. The NON_BOOLEAN_FALSE_ATTRIBUTES set on major-9.0 already tracks this mapping from the dash-cased side, so it'd be good to have the reflected side covered too while we're in here. Up to you if it's in scope.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok, I tried to fix this as well.

expect(Object.keys((div as any).__events)).toEqual(['ionClick']);
});

it('should not write undefined props to a dom node', () => {

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.

Could you add a component-level test alongside these? The bug is an interaction between the render() filter, which omits undefined so React emits no attribute, and componentDidUpdate, which then writes it back, and a direct attachProps call can't see that. On main a wrapper mounted with id={undefined} still comes out as <ion-toggle id="undefined">, and these two tests wouldn't catch that coming back at the wrapper level.

There's precedent in this directory: createInlineOverlayComponent.spec.tsx already drives a generated wrapper with @testing-library/react, and createComponent pulls nothing from @ionic/core so there's no mocking needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added another test. I'll also submit a version of this for v9 shortly.

Comment thread packages/react/src/components/__tests__/utils.spec.ts Outdated
@ptmkenny

Copy link
Copy Markdown
Contributor Author

@ShaneK Thanks for the review! I have finished updating the PR and I also reworked it for v9 here: #31349 (If there is a way you would prefer I submit PRs that affect multiple versions, please let me know)

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

Labels

package: react @ionic/react package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: @ionic/react writes the string "undefined" into reflected attributes when no optional prop is set

2 participants