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
24 changes: 24 additions & 0 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,30 @@ The `@ionic/react-router` package now requires React Router v6. React Router v5
| react-router | 6.4.0+ |
| react-router-dom | 6.4.0+ |

**TypeScript**

The `@ionic/react` package now requires TypeScript 5.4 or later. Its type definitions use `NoInfer`, which TypeScript added in 5.4. This matches the minimum that `@ionic/angular` already requires.

**Typed Overlay Hook Props**

The `useIonModal` and `useIonPopover` hooks type `componentProps` against the component they are given, instead of accepting `any`. Props that do not match the component are a compile error, and `componentProps` is required when the component declares required props. Applications passing incorrect props will see new type errors at build time rather than failing at runtime.

```diff
const Modal: React.FC<{ title: string }> = ({ title }) => <IonContent>{title}</IonContent>;

- const [present, dismiss] = useIonModal(Modal, { subtitle: 'Wrong' });
+ const [present, dismiss] = useIonModal(Modal, { title: 'Hello' });
```

Props are read from the component rather than from `componentProps`, so a component declared inline needs its props annotated:

```diff
- const [present, dismiss] = useIonModal(({ name }) => <div>Hello {name}.</div>, { name: 'Dave' });
+ const [present, dismiss] = useIonModal(({ name }: { name: string }) => <div>Hello {name}.</div>, { name: 'Dave' });
```

Passing a JSX element rather than a component is unchanged, and `componentProps` is not type checked in that case.

React Router v6 introduces several API changes that will require updates to your application's routing configuration:

**Route Definition Changes**
Expand Down
2 changes: 2 additions & 0 deletions packages/migrate/src/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { angularVersion } from './v9/angular-version.js';
import { angularBrowserPolicy } from './v9/angular-browser-policy.js';
import { angularBrowserPolicyManual } from './v9/angular-browser-policy-manual.js';
import { reactDeps } from './v9/react-deps.js';
import { reactTypescript } from './v9/react-typescript.js';
import { reactRouter6Routes } from './v9/react-router-6-routes.js';
import { reactRouter6Code } from './v9/react-router-6-code.js';
import { vueDeps } from './v9/vue-deps.js';
Expand Down Expand Up @@ -51,6 +52,7 @@ export const allMigrations: Migration[] = [
angularBrowserPolicy,
angularBrowserPolicyManual,
reactDeps,
reactTypescript,
reactRouter6Routes,
reactRouter6Code,
vueDeps,
Expand Down
2 changes: 1 addition & 1 deletion packages/migrate/src/migrations/v9/react-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createDepsMigration } from '../../ast/deps-migration.js';
* `react-router-6-code`.
*
* React is raised only to the 18 floor v9 requires; a newer major is the app's
* call.
* call. TypeScript is handled by `react-typescript`.
*
* Refer to https://ionicframework.com/docs/updating/9-0#react
*/
Expand Down
17 changes: 17 additions & 0 deletions packages/migrate/src/migrations/v9/react-typescript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createDepsMigration } from '../../ast/deps-migration.js';

/**
* Ionic 9 requires TypeScript 5.4 or later. `@ionic/react`'s published types use
* `NoInfer`, which TypeScript added in 5.4. A higher pin is left alone.
*
* Pinned with a caret, unlike the tilde `angular-typescript` uses: React has no
* narrow peer range to satisfy, so any later 5.x is fine.
*
* Refer to https://ionicframework.com/docs/updating/9-0#react-typescript
*/
export const reactTypescript = createDepsMigration({
id: 'react-typescript',
framework: 'react',
docsUrl: 'https://ionicframework.com/docs/updating/9-0#react-typescript',
bumps: [['typescript', '^5.4.0']],
});
84 changes: 84 additions & 0 deletions packages/migrate/test/react-typescript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';

import { createInMemoryContext } from '../src/context.js';
import { selectMigrations } from '../src/registry.js';
import { allMigrations } from '../src/migrations/index.js';
import { reactTypescript } from '../src/migrations/v9/react-typescript.js';

describe('react-typescript', () => {
it('raises TypeScript to the 5.4 floor @ionic/react requires', () => {
const ctx = createInMemoryContext({
'package.json': JSON.stringify({ devDependencies: { typescript: '^4.9.5' } }, null, 2),
});

reactTypescript.fix!(ctx);

expect(JSON.parse(ctx.readFile('package.json')!).devDependencies.typescript).toBe('^5.4.0');
});

it('raises a pin declared in dependencies rather than devDependencies', () => {
const ctx = createInMemoryContext({
'package.json': JSON.stringify({ dependencies: { typescript: '~5.0.4' } }, null, 2),
});

reactTypescript.fix!(ctx);

expect(JSON.parse(ctx.readFile('package.json')!).dependencies.typescript).toBe('^5.4.0');
});

it('does not downgrade a pin already above the floor', () => {
const ctx = createInMemoryContext({
'package.json': JSON.stringify({ devDependencies: { typescript: '^5.9.2' } }, null, 2),
});

expect(reactTypescript.detect(ctx)).toEqual([]);
});

it('leaves a TypeScript 6 pin alone', () => {
// The caret target is a floor, not a ceiling, so a later major stays put.
const ctx = createInMemoryContext({
'package.json': JSON.stringify({ devDependencies: { typescript: '^6.0.0' } }, null, 2),
});

expect(reactTypescript.detect(ctx)).toEqual([]);
});

it('adds nothing to a project that does not use TypeScript', () => {
const ctx = createInMemoryContext({
'package.json': JSON.stringify({ dependencies: { react: '^18.0.0' } }, null, 2),
});

reactTypescript.fix!(ctx);
const pkg = JSON.parse(ctx.readFile('package.json')!);

expect(reactTypescript.detect(ctx)).toEqual([]);
expect(pkg.devDependencies?.typescript).toBeUndefined();
expect(pkg.dependencies.typescript).toBeUndefined();
});

it('leaves a range it cannot parse alone', () => {
const ctx = createInMemoryContext({
'package.json': JSON.stringify({ devDependencies: { typescript: 'catalog:' } }, null, 2),
});

expect(reactTypescript.detect(ctx)).toEqual([]);
});

it('reports the change it would make', () => {
const ctx = createInMemoryContext({
'package.json': JSON.stringify({ devDependencies: { typescript: '^4.9.5' } }, null, 2),
});

expect(reactTypescript.detect(ctx)).toEqual([
{ filePath: 'package.json', line: 1, detail: 'set typescript to ^5.4.0' },
]);
});

it('is selected for a React project and not an Angular one', () => {
const selectedFor = (framework: 'react' | 'angular') =>
selectMigrations(allMigrations, { fromMajor: 8, toMajor: 9, frameworks: [framework] }).map((m) => m.id);

expect(selectedFor('react')).toContain('react-typescript');
expect(selectedFor('angular')).not.toContain('react-typescript');
});
});
153 changes: 153 additions & 0 deletions packages/react/src/hooks/__tests__/overlay-hook-types.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { ComponentClass, FC, ForwardRefExoticComponent, MemoExoticComponent, RefAttributes } from 'react';

import type { useIonModal } from '../useIonModal';
import type { useIonPopover } from '../useIonPopover';

// The hooks are type-only imports, re-declared here. `@ionic/core/components` is ESM
// and Jest runs these specs as CommonJS, so importing them for real fails to load.
declare const useIonModalSignature: typeof useIonModal;
declare const useIonPopoverSignature: typeof useIonPopover;

interface RequiredProps {
title: string;
count?: number;
}

interface OptionalProps {
count?: number;
}

declare const RequiredFunctionComponent: FC<RequiredProps>;
declare const RequiredClassComponent: ComponentClass<RequiredProps>;
declare const RequiredMemoComponent: MemoExoticComponent<FC<RequiredProps>>;
declare const RequiredForwardRefComponent: ForwardRefExoticComponent<RequiredProps & RefAttributes<HTMLDivElement>>;
declare const OptionalPropsComponent: FC<OptionalProps>;
declare const NoPropsComponent: FC;
declare const DismissableComponent: FC<{ dismiss: (data: string, role: string) => void }>;
declare const UntypedComponent: FC<any>;
declare const overlayElement: JSX.Element;

// None of these functions are invoked. They exist so `npm run typecheck` checks the
// calls inside them.
function componentPropsAreTypeChecked() {
useIonModalSignature(RequiredFunctionComponent, { title: 'Modal', count: 1 });
useIonModalSignature(RequiredClassComponent, { title: 'Modal' });
useIonModalSignature(RequiredMemoComponent, { title: 'Modal' });
useIonModalSignature(RequiredForwardRefComponent, { title: 'Modal' });
useIonPopoverSignature(RequiredFunctionComponent, { title: 'Popover', count: 1 });
useIonPopoverSignature(RequiredClassComponent, { title: 'Popover' });

useIonPopoverSignature(RequiredMemoComponent, { title: 'Popover' });
useIonPopoverSignature(RequiredForwardRefComponent, { title: 'Popover' });

// @ts-expect-error a required prop may not be omitted
useIonModalSignature(RequiredFunctionComponent, { count: 1 });
// @ts-expect-error
useIonPopoverSignature(RequiredClassComponent, { count: 1 });

// @ts-expect-error unknown props are not accepted
useIonModalSignature(RequiredFunctionComponent, { title: 'Modal', unknown: true });
// @ts-expect-error
useIonPopoverSignature(RequiredFunctionComponent, { title: 'Popover', unknown: true });

// @ts-expect-error props must match the declared types
useIonModalSignature(RequiredFunctionComponent, { title: 1 });
// @ts-expect-error
useIonPopoverSignature(RequiredFunctionComponent, { title: 1 });

// @ts-expect-error a forwarded ref does not exempt a component from the check
useIonModalSignature(RequiredForwardRefComponent, { title: 1 });
}

// A rest-tuple signature still accepts an explicit type argument, so apps can pin the
// props type instead of relying on inference.
function explicitTypeArgumentsAreSupported() {
useIonModalSignature<RequiredProps>(RequiredFunctionComponent, { title: 'Modal' });
useIonPopoverSignature<RequiredProps>(RequiredFunctionComponent, { title: 'Popover' });

// @ts-expect-error an explicit type argument still checks the props
useIonModalSignature<RequiredProps>(RequiredFunctionComponent, { count: 1 });
}

// Every call below omits `componentProps`, which `RequiredProps` does not allow.
function componentPropsAreRequiredWhenTheComponentRequiresThem() {
// @ts-expect-error
useIonModalSignature(RequiredFunctionComponent);
// @ts-expect-error
useIonModalSignature(RequiredClassComponent);
// @ts-expect-error
useIonModalSignature(RequiredMemoComponent);
// @ts-expect-error
useIonPopoverSignature(RequiredFunctionComponent);
// @ts-expect-error
useIonPopoverSignature(RequiredClassComponent);
// @ts-expect-error
useIonPopoverSignature(RequiredMemoComponent);
}

// Untyped components keep the pre-v9 behavior, so upgrading apps don't get a new
// build error out of this.
function untypedComponentsStayPermissive() {
useIonModalSignature(UntypedComponent);
useIonModalSignature(UntypedComponent, { anything: true });
useIonPopoverSignature(UntypedComponent);
useIonPopoverSignature(UntypedComponent, { anything: true });
}

function componentPropsAreOptionalWhenTheComponentHasNoRequiredProps() {
useIonModalSignature(NoPropsComponent);
useIonPopoverSignature(NoPropsComponent);

useIonModalSignature(OptionalPropsComponent);
useIonModalSignature(OptionalPropsComponent, { count: 1 });
useIonPopoverSignature(OptionalPropsComponent);
useIonPopoverSignature(OptionalPropsComponent, { count: 1 });
}

function jsxElementsRemainPermissive() {
useIonModalSignature(overlayElement);
useIonModalSignature(overlayElement, { anything: true });
useIonPopoverSignature(overlayElement);
useIonPopoverSignature(overlayElement, { anything: true });
}

// Inline components need their props annotated, since `Props` is inferred from the
// component rather than from `componentProps`. See the `NoInfer` note in `useIonModal`.
function inlineComponentsAnnotateTheirProps() {
useIonModalSignature(({ name }: { name: string }) => <div>Hello {name}.</div>, { name: 'Dave' });
useIonPopoverSignature(({ name }: { name: string }) => <div>Hello {name}.</div>, { name: 'Dave' });
}

// Overlays commonly pass `dismiss` back to the component through `componentProps`.
// That reads the binding the hook is still declaring, so it only compiles while
// `componentProps` stays out of inference.
function selfReferencingDismissCompiles() {
const [, dismissModal] = useIonModalSignature(DismissableComponent, {
dismiss: (data: string, role: string) => dismissModal(data, role),
});

const [, dismissPopover] = useIonPopoverSignature(DismissableComponent, {
dismiss: (data: string, role: string) => dismissPopover(data, role),
});
}

// Referenced so `noUnusedLocals` doesn't flag them. Keeping them unexported is what
// keeps the emitted declaration file empty.
void [
componentPropsAreTypeChecked,
explicitTypeArgumentsAreSupported,
componentPropsAreRequiredWhenTheComponentRequiresThem,
untypedComponentsStayPermissive,
componentPropsAreOptionalWhenTheComponentHasNoRequiredProps,
jsxElementsRemainPermissive,
inlineComponentsAnnotateTheirProps,
selfReferencingDismissCompiles,
];

describe('overlay hook types', () => {
it('type checks component props at compile time', () => {
// The assertions in this file are enforced by `npm run typecheck`, which CI runs
// for this package. ts-jest sets `isolatedModules` in `tsconfig.spec.json` and so
// doesn't type check, which leaves nothing to assert at runtime.
});
});
21 changes: 20 additions & 1 deletion packages/react/src/hooks/useIonModal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ModalOptions } from '@ionic/core/components';
import { modalController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-modal.js';
import type { ComponentType } from 'react';
import { useCallback } from 'react';

import type { ReactComponentOrElement } from '../models/ReactComponentOrElement';
Expand All @@ -10,12 +11,30 @@ import { useOverlay } from './useOverlay';

// TODO(FW-2959): types

// The `NoInfer` below keeps `componentProps` out of inference, so `Props` comes from
// the component alone. Overlays commonly pass `dismiss` back in through
// `componentProps`, and inferring from it would need the type of `dismiss` while that
// binding is still being declared, which TypeScript rejects as circular. The cost is
// that an inline component with no annotated props resolves to `{}`.

/**
* A hook for presenting/dismissing an IonModal component
* @param component The component that the modal will show. Can be a React Component or a functional component
* @param componentProps The props that will be passed to the component. Required when the component declares required props
* @returns Returns the present and dismiss methods in an array
*/
export function useIonModal<Props extends object>(
...args: {} extends Props
? [component: ComponentType<Props>, componentProps?: NoInfer<Props>]
: [component: ComponentType<Props>, componentProps: NoInfer<Props>]
): UseIonModalResult;
/**
* A hook for presenting/dismissing an IonModal component
* @param component The component that the modal will show. Can be a React Component, a functional component, or a JSX Element
* @param component A JSX Element that the modal will show. Props are already bound to the element, so `componentProps` is not type checked
* @param componentProps The props that will be passed to the component, if required
* @returns Returns the present and dismiss methods in an array
*/
export function useIonModal(component: JSX.Element, componentProps?: any): UseIonModalResult;
export function useIonModal(component: ReactComponentOrElement, componentProps?: any): UseIonModalResult {
const controller = useOverlay<ModalOptions, HTMLIonModalElement>(
'IonModal',
Expand Down
19 changes: 17 additions & 2 deletions packages/react/src/hooks/useIonPopover.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PopoverOptions } from '@ionic/core/components';
import { popoverController } from '@ionic/core/components';
import { defineCustomElement } from '@ionic/core/components/ion-popover.js';
import type { ComponentType } from 'react';
import { useCallback } from 'react';

import type { ReactComponentOrElement } from '../models/ReactComponentOrElement';
Expand All @@ -10,12 +11,26 @@ import { useOverlay } from './useOverlay';

// TODO(FW-2959): types

// The `NoInfer` below does the same job as in `useIonModal`. See the note there.

/**
* A hook for presenting/dismissing an IonPopover component
* @param component The component that the popover will show. Can be a React Component or a functional component
* @param componentProps The props that will be passed to the component. Required when the component declares required props
* @returns Returns the present and dismiss methods in an array
*/
export function useIonPopover<Props extends object>(
...args: {} extends Props
? [component: ComponentType<Props>, componentProps?: NoInfer<Props>]
: [component: ComponentType<Props>, componentProps: NoInfer<Props>]
): UseIonPopoverResult;
/**
* A hook for presenting/dismissing an IonPicker component
* @param component The component that the popover will show. Can be a React Component, a functional component, or a JSX Element
* A hook for presenting/dismissing an IonPopover component
* @param component A JSX Element that the popover will show. Props are already bound to the element, so `componentProps` is not type checked
* @param componentProps The props that will be passed to the component, if required
* @returns Returns the present and dismiss methods in an array
*/
export function useIonPopover(component: JSX.Element, componentProps?: any): UseIonPopoverResult;
export function useIonPopover(component: ReactComponentOrElement, componentProps?: any): UseIonPopoverResult {
const controller = useOverlay<PopoverOptions, HTMLIonPopoverElement>(
'IonPopover',
Expand Down
Loading
Loading