Skip to content

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

Description

@ptmkenny

Prerequisites

Ionic Framework Version

v8.x

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.

Expected Behavior

A prop whose value is undefined should not be written to the element at all, so no
attribute appears — the behavior React gives for host elements, and the behavior the
wrapper's own render() already implements. A prop that previously had a value and is set
to undefined should have its attribute removed.

Steps to Reproduce

  1. git clone https://github.com/ptmkenny/ionic-react-router-6-test.git
  2. cd ionic-react-router-6-test && git switch react-attach-props-undefined
  3. npm install && npm run dev
  4. Open the app at http://localhost:5173/ (Tab 1). It renders one IonToggle through a
    wrapper that forwards an optional id, and prints the attribute the element actually
    received.
  5. Observe: the page reports id attribute on <ion-toggle>: "undefined" where (none) is
    expected. Confirm in devtools that the element is <ion-toggle id="undefined" …>.
  6. Press Pass id="real-id" — the attribute correctly becomes "real-id".
  7. Press Pass id=undefined and observe: the attribute stays "undefined" instead of
    being removed.

Code Reproduction URL

https://github.com/ptmkenny/ionic-react-router-6-test/tree/react-attach-props-undefined

Ionic Info

Ionic:

   Ionic CLI       : 7.2.1 (/home/node/.npm/_npx/f6fddb685269761d/node_modules/@ionic/cli)
   Ionic Framework : @ionic/react 8.7.12-dev.11765219790.17cbe2e9

Capacitor:

   Capacitor CLI      : 8.0.0
   @capacitor/android : not installed
   @capacitor/core    : 8.0.0
   @capacitor/ios     : not installed

Utility:

   cordova-res : not installed globally
   native-run  : 2.0.1

System:

   NodeJS : v24.19.0 (/usr/local/bin/node)
   npm    : 11.17.0
   OS     : Linux 6.18

Additional Information

Analyzed and prepared with Claude Opus.

Root cause

packages/react/src/components/react-component-lib/utils/attachProps.ts assigns every
incoming prop onto the custom element without checking whether it has a value:

(node as any)[name] = newProps[name];
const propType = typeof newProps[name];
if (propType === 'string') {
  node.setAttribute(camelToDashCase(name), newProps[name]);
}

id is a reflected non-nullable DOMString IDL attribute, so per WebIDL node.id = undefined stringifies the value and the element ends up with id="undefined". The
typeof === 'string' guard never comes into it — the property assignment has already
reflected to the attribute. The same applies to title, slot, name, lang and dir,
and to any Stencil @Prop() declared with reflect: true.

The wrapper's two prop paths disagree with each other. render() in
createComponent.tsx deliberately filters these values out:

// we should only render strings, booleans, and numbers as attrs in html.
// objects, functions, arrays etc get synced via properties on mount.
const type = typeof value;
if (type === 'string' || type === 'boolean' || type === 'number') {
  acc[camelToDashCase(name)] = value;
}

undefined fails that check, so React never emits the attribute — and then attachProps,
called from componentDidMount, writes it anyway.

A second, related gap explains step 7: attachProps receives oldProps but uses it only
for className, so it has no notion of a prop being removed. A removal therefore writes
"undefined" over the previous value instead of clearing it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions