Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"@playwright/test": "^1.62.1",
"@rollup/plugin-node-resolve": "^8.4.0",
"@rollup/plugin-virtual": "^2.0.3",
"@stencil/angular-output-target": "^1.4.1",
"@stencil/angular-output-target": "^1.5.0",
"@stencil/react-output-target": "^1.6.2",
"@stencil/sass": "^3.0.9",
"@stencil/vue-output-target": "0.14.2",
Expand Down
2 changes: 2 additions & 0 deletions core/stencil.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const getAngularOutputTargets = () => {
directivesArrayFile: '../packages/angular/src/lazy/directives/proxies-list.ts',
excludeComponents,
outputType: 'component',
booleanAttributes: true,

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.

The <ion-modal [handle]="42"> failure in the description has a flip side I don't think we've accounted for. These inputs had no type at all before, so anything that compiled and isn't boolean, any or nullable now fails too. Something like [disabled]="items.length" is the common one, and it's the same on Angular 18 through 22.

What gets emitted is ngAcceptInputType_<prop>: boolean | string | null | undefined. Upstream chose the narrow type on purpose, the helper's own comment says widening it to unknown would let any expression through, so this is intended rather than a slip. It does mean every boolean input in the library gets stricter in a minor though.

Could we check the breaking change box, or scope the claim in the description? It probably wants a changelog note either way.

}),
angularOutputTarget({
componentCorePackage,
Expand Down Expand Up @@ -66,6 +67,7 @@ const getAngularOutputTargets = () => {
outputType: 'standalone',
// Emit each component in a separate file rather than putting them all in one large file.
esModules: true,
booleanAttributes: true,

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.

Is there an ionic-docs PR for this one? I couldn't find one linked. The part consumers will get wrong is that null and undefined pass through instead of coercing to false, since that's the opposite of what Angular's own booleanAttribute does.

The "Standalone Directive" section of docs/component-guide.md could use a line too. It's where someone goes to add a hand-written wrapper and it still points at ion-checkbox and ion-toggle for boolean inputs without mentioning the transform, so the next one added won't match its neighbors.

})
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,30 @@ import type { Components } from '@ionic/core';

import { Config } from '../../providers/config';
import { NavController } from '../../providers/nav-controller';
import { nullableBooleanAttribute } from '../../utils/boolean-attribute';
import { ProxyCmp } from '../../utils/proxy';

import { IonRouterOutlet } from './router-outlet';

const BACK_BUTTON_INPUTS = ['color', 'defaultHref', 'disabled', 'icon', 'mode', 'routerAnimation', 'text', 'type'];
const BACK_BUTTON_INPUTS = [
'color',
'defaultHref',
{ name: 'disabled', transform: nullableBooleanAttribute },
'icon',
'mode',
'routerAnimation',
'text',
'type',
];

/* ProxyCmp only needs the names, and runs at runtime rather than through the Angular compiler. */
const BACK_BUTTON_PROXY_INPUTS = BACK_BUTTON_INPUTS.map((input) => (typeof input === 'string' ? input : input.name));

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export declare interface IonBackButton extends Components.IonBackButton {}

@ProxyCmp({
inputs: BACK_BUTTON_INPUTS,
inputs: BACK_BUTTON_PROXY_INPUTS,
})
@Directive({
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
Expand Down
14 changes: 12 additions & 2 deletions packages/angular/src/common/directives/navigation/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@ import {
import type { Components } from '@ionic/core';

import { AngularDelegate } from '../../providers/angular-delegate';
import { nullableBooleanAttribute } from '../../utils/boolean-attribute';
import { ProxyCmp, proxyOutputs } from '../../utils/proxy';

const NAV_INPUTS = ['animated', 'animation', 'root', 'rootParams', 'swipeGesture'];
const NAV_INPUTS = [
{ name: 'animated', transform: nullableBooleanAttribute },
'animation',
'root',
'rootParams',
{ name: 'swipeGesture', transform: nullableBooleanAttribute },
];

/* ProxyCmp only needs the names, and runs at runtime rather than through the Angular compiler. */
const NAV_PROXY_INPUTS = NAV_INPUTS.map((input) => (typeof input === 'string' ? input : input.name));

const NAV_METHODS = [
'push',
Expand Down Expand Up @@ -42,7 +52,7 @@ export declare interface IonNav extends Components.IonNav {
}

@ProxyCmp({
inputs: NAV_INPUTS,
inputs: NAV_PROXY_INPUTS,
methods: NAV_METHODS,
})
@Directive({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { distinctUntilChanged, filter, switchMap } from 'rxjs/operators';

import { Config } from '../../providers/config';
import { NavController } from '../../providers/nav-controller';
import { nullableBooleanAttribute } from '../../utils/boolean-attribute';

import { StackController } from './stack-controller';
import { RouteView, StackDidChangeEvent, StackWillChangeEvent, getUrl, isTabSwitch } from './stack-utils';
Expand All @@ -39,7 +40,12 @@ import { RouteView, StackDidChangeEvent, StackWillChangeEvent, getUrl, isTabSwit
selector: 'ion-router-outlet',
exportAs: 'outlet',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['animated', 'animation', 'mode', 'swipeGesture'],
inputs: [
{ name: 'animated', transform: nullableBooleanAttribute },
'animation',
'mode',
{ name: 'swipeGesture', transform: nullableBooleanAttribute },
],
})
export abstract class IonRouterOutlet implements OnDestroy, OnInit {
abstract outletContent: any;
Expand Down
24 changes: 14 additions & 10 deletions packages/angular/src/common/overlays/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@angular/core';
import type { Components, ModalBreakpointChangeEventDetail, ModalDragEventDetail } from '@ionic/core/components';

import { nullableBooleanAttribute } from '../utils/boolean-attribute';
import { ProxyCmp, proxyOutputs } from '../utils/proxy';

export declare interface IonModal extends Components.IonModal {
Expand Down Expand Up @@ -63,30 +64,33 @@ export declare interface IonModal extends Components.IonModal {
}

const MODAL_INPUTS = [
'animated',
'keepContentsMounted',
{ name: 'animated', transform: nullableBooleanAttribute },
{ name: 'keepContentsMounted', transform: nullableBooleanAttribute },
'backdropBreakpoint',
'backdropDismiss',
{ name: 'backdropDismiss', transform: nullableBooleanAttribute },
'breakpoints',
'canDismiss',
'cssClass',
'enterAnimation',
'expandToScroll',
{ name: 'expandToScroll', transform: nullableBooleanAttribute },
'event',
'focusTrap',
'handle',
{ name: 'focusTrap', transform: nullableBooleanAttribute },
{ name: 'handle', transform: nullableBooleanAttribute },
'handleBehavior',
'initialBreakpoint',
'isOpen',
'keyboardClose',
{ name: 'isOpen', transform: nullableBooleanAttribute },
{ name: 'keyboardClose', transform: nullableBooleanAttribute },
'leaveAnimation',
'mode',
'presentingElement',
'showBackdrop',
{ name: 'showBackdrop', transform: nullableBooleanAttribute },
'translucent',
'trigger',
];

/* ProxyCmp only needs the names, and runs at runtime rather than through the Angular compiler. */
const MODAL_PROXY_INPUTS = MODAL_INPUTS.map((input) => (typeof input === 'string' ? input : input.name));

const MODAL_METHODS = [
'present',
'dismiss',
Expand All @@ -97,7 +101,7 @@ const MODAL_METHODS = [
];

@ProxyCmp({
inputs: MODAL_INPUTS,
inputs: MODAL_PROXY_INPUTS,
methods: MODAL_METHODS,
})
/**
Expand Down
26 changes: 15 additions & 11 deletions packages/angular/src/common/overlays/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@angular/core';
import type { Components } from '@ionic/core/components';

import { nullableBooleanAttribute } from '../utils/boolean-attribute';
import { ProxyCmp, proxyOutputs } from '../utils/proxy';

export declare interface IonPopover extends Components.IonPopover {
Expand Down Expand Up @@ -48,32 +49,35 @@ export declare interface IonPopover extends Components.IonPopover {

const POPOVER_INPUTS = [
'alignment',
'animated',
'arrow',
'keepContentsMounted',
'backdropDismiss',
{ name: 'animated', transform: nullableBooleanAttribute },
{ name: 'arrow', transform: nullableBooleanAttribute },
{ name: 'keepContentsMounted', transform: nullableBooleanAttribute },
{ name: 'backdropDismiss', transform: nullableBooleanAttribute },
'cssClass',
'dismissOnSelect',
{ name: 'dismissOnSelect', transform: nullableBooleanAttribute },
'enterAnimation',
'event',
'focusTrap',
'isOpen',
'keyboardClose',
{ name: 'focusTrap', transform: nullableBooleanAttribute },
{ name: 'isOpen', transform: nullableBooleanAttribute },
{ name: 'keyboardClose', transform: nullableBooleanAttribute },
'leaveAnimation',
'mode',
'showBackdrop',
'translucent',
{ name: 'showBackdrop', transform: nullableBooleanAttribute },
{ name: 'translucent', transform: nullableBooleanAttribute },
'trigger',
'triggerAction',
'reference',
'size',
'side',
];

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
];
{ name: 'keyboardEvents', transform: nullableBooleanAttribute },
];

The keyboardEvents prop is a public documented boolean on ion-popover and it's missing from this array entirely, so it's the one popover boolean this PR doesn't reach. Checked that adding it compiles and wires up properly. Pre-existing, same class of thing as the required gap over on checkbox.

While you're in here, the modal array has the mirror problem, it lists translucent and event and neither of those exists on ion-modal. I think the standalone input wrapper has a couple in the same state too.


/* ProxyCmp only needs the names, and runs at runtime rather than through the Angular compiler. */
const POPOVER_PROXY_INPUTS = POPOVER_INPUTS.map((input) => (typeof input === 'string' ? input : input.name));

const POPOVER_METHODS = ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss'];

@ProxyCmp({
inputs: POPOVER_INPUTS,
inputs: POPOVER_PROXY_INPUTS,
methods: POPOVER_METHODS,
})
/**
Expand Down
36 changes: 36 additions & 0 deletions packages/angular/src/common/utils/boolean-attribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Duplicates `angular-component-lib/boolean-attribute.ts`, which the output
* target copies next to each generated proxies file and so is not reachable
* from here. `proxy.ts` duplicates `ProxyCmp` for the same reason. Refer to
* the TODO at the top of `proxy.ts`.
*/

/**
* Transforms a value to a boolean so that boolean properties can be set by attribute presence,

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.

Did this end up confirmed? The original report was an editor warning rather than a build failure, and I'm not sure the new option silences it on its own.

From the compiler side it doesn't. Writing <ion-item button detail> compiles clean with strictTemplates on against both this branch and the base, in both entry points, on Angular 18 through 22. So whether #30822 is actually fixed comes down entirely to the language service. If it isn't silenced there, that's the second Stencil option and probably its own call.

* e.g. `<ion-modal handle>` instead of `<ion-modal [handle]="true">`.
*
* Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare
* attribute) becomes `true` and `'false'` becomes `false`.
*
* Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than
* coerced to `false`. Components frequently treat them as a state distinct from `false`, and both
* reach inputs routinely from the `async` pipe before its first emission and from form control
* values:
*
* ```tsx

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.

This is the only one of the three copies anyone will actually edit, and it's the shortest. It drops the notes the generated copy carries about why Angular's booleanAttribute isn't imported, and about the parameter type being what ngAcceptInputType derives from, so widening it to unknown would quietly loosen template checking everywhere. That second one especially is the kind of thing someone would undo without realizing.

Meanwhile the tsx example and the async pipe paragraph read more like PR description material. I think trading one for the other would leave this better off, but up to you!

* // `undefined` means "decide based on the mode", which is not the same as `false`
* const showDetail = detail !== undefined ? detail : mode === 'ios';
*
* // a strict comparison also behaves differently for `null` than it does for `false`
* const showHandle = handle !== false;
* ```
*
* Declared as a function rather than an arrow constant because Angular has to resolve input
* transforms statically when compiling a library in partial compilation mode.
*/
export function nullableBooleanAttribute(value: boolean | string | null | undefined): boolean | null | undefined {
if (value === null || value === undefined) {
return value;
}
return typeof value === 'boolean' ? value : value !== 'false';

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.

Anything that isn't a boolean, null or undefined falls into the string branch here, so 0 and NaN come back true. Before this, Angular wrote the raw value straight through and Stencil coerced 0 to false, so [disabled]="items.length" on an empty list flips from enabled to disabled. With strictTemplates on you'd get a compile error instead, without it it's silent. Empty string, null, undefined and objects don't change, so the focusTrap === false cases in the ticket are all fine.

Coercing only strings would keep the feature and the old behavior. The catch is that the two generated copies of this helper are byte-identical to what @stencil/angular-output-target 1.5.0 ships, and the whole angular-component-lib directory gets recopied on every core build, so changing it here alone would leave the hand-written wrappers behaving differently from the generated ones. It'd have to go upstream again.

Mostly flagging it so it's a deliberate call rather than something we notice later, since changing it afterwards is its own behavior change.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* eslint-disable */
/* tslint:disable */

/**
* Transforms a value to a boolean so that boolean properties can be set by attribute presence,
* e.g. `<my-component disabled>` instead of `<my-component [disabled]="true">`.
*
* Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare
* attribute) becomes `true` and `'false'` becomes `false`.
*
* Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than
* coerced to `false`. Components frequently treat them as a state distinct from `false`:
*
* ```tsx
* // `undefined` means "decide based on the mode", which is not the same as `false`
* const showDetail = detail !== undefined ? detail : mode === 'ios';
*
* // a strict comparison also behaves differently for `null` than it does for `false`
* const showHandle = handle !== false;
* ```
*
* Both values reach inputs routinely in Angular templates, from the `async` pipe before its
* first emission and from form control values, so coercing them would change the behavior of
* bindings that work today.
*
* This is implemented here rather than imported from `@angular/core` so that consumers on
* Angular versions without `booleanAttribute` are unaffected by this file being generated.
*
* The parameter type is what Angular derives `ngAcceptInputType_*` from, so it decides which
* template bindings compile. Widening it to `unknown` would let any expression through.
*
* Declared as a function rather than an arrow constant because Angular has to resolve input
* transforms statically when compiling a library in partial compilation mode.
*
* This lives in its own file, separate from `utils.ts`, so that it carries no runtime imports.
* That keeps it independently type-checkable without pulling `rxjs` in for `proxyOutputs`.
*/
export function nullableBooleanAttribute(value: boolean | string | null | undefined): boolean | null | undefined {
if (value === null || value === undefined) {
return value;
}
return typeof value === 'boolean' ? value : value !== 'false';
}
Loading
Loading