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

Labels

type: buga confirmed bug report

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions