Skip to content

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

Open
ptmkenny wants to merge 1 commit into
ionic-team:mainfrom
ptmkenny:react-attach-props-undefined
Open

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

Conversation

@ptmkenny

Copy link
Copy Markdown
Contributor

Issue number: resolves #31344


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.

Comment on lines +33 to +44
/**
* An undefined prop must never be assigned to the element. Reflected
* properties such as `id`, `title` and `slot` stringify whatever they
* are given, so `node.id = undefined` leaves the element with the
* literal attribute `id="undefined"`. `render()` already omits
* undefined props for the same reason.
*
* A prop that had a value and no longer does is a removal, so clear
* it the way React clears a removed attribute: reset the property
* first, for props with no attribute to mirror, then drop the
* attribute that a reflected property left behind.
*/

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.

Suggested change
/**
* An undefined prop must never be assigned to the element. Reflected
* properties such as `id`, `title` and `slot` stringify whatever they
* are given, so `node.id = undefined` leaves the element with the
* literal attribute `id="undefined"`. `render()` already omits
* undefined props for the same reason.
*
* A prop that had a value and no longer does is a removal, so clear
* it the way React clears a removed attribute: reset the property
* first, for props with no attribute to mirror, then drop the
* attribute that a reflected property left behind.
*/
/**
* Reflected properties such as `id`, `title` and `slot` stringify
* whatever they are given, so `node.id = undefined` leaves the element
* with the literal attribute `id="undefined"`. Never assign an
* undefined value.
*
* A prop that had a value and no longer does is a removal: reset the
* property, which covers props with no attribute to mirror, then drop
* the attribute a reflected property left behind.
*/

Two claims in here don't quite hold. The render() filter drops undefined as a side effect of its string | boolean | number allowlist, and its own comment gives the reason: objects, functions and arrays get synced as properties instead. Those are dropped by the same branch, so the "same reason" cross-reference contradicts itself if you follow it.

And React clears an attribute with removeAttribute, it doesn't assign undefined to the property first. The property write here is for props with no mirroring attribute, which is worth keeping in the comment, just not as React parity.

*/
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.

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.


expect(div.hasAttribute('id')).toEqual(false);
expect(div.hasAttribute('title')).toEqual(false);
expect((div as any).testprop).toEqual(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.

Suggested change
expect((div as any).testprop).toEqual(undefined);
expect('testprop' in div).toBe(false);

Nit: oldProps defaults to {} here, so nothing gets assigned and testprop was never on the node. The assertion as written passes on main too, so it isn't guarding anything. Checking the property is absent does fail before the fix. The same line in the second test is fine, since testprop actually gets set first.

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