` resolves to a real props type instead
+ * of `any` - the same thing an installed `@types/react` gives the disk context.
+ *
+ * Declaring it as `export =` is what makes `esModuleInterop` load-bearing here,
+ * as it is in the published types.
+ */
+export const REACT_TYPES = `
+declare namespace React {
+ type ReactNode = any;
+ interface FunctionComponent {
+ (props: P): ReactNode;
+ }
+ type FC
= FunctionComponent
;
+ class Component
{
+ props: Readonly
;
+ state: Readonly;
+ render(): ReactNode;
+ }
+ interface NamedExoticComponent
{
+ (props: P): ReactNode;
+ displayName?: string;
+ }
+ function memo
(component: FunctionComponent
): NamedExoticComponent
;
+}
+export = React;
+export as namespace React;
+`;
+
+/** A stand-in for `@ionic/react`, so a fixture's hook import resolves. */
+const IONIC_REACT_TYPES = `
+export declare function useIonModal(component: any, componentProps?: any): [(opts?: any) => void, (data?: any, role?: string) => void];
+export declare function useIonPopover(component: any, componentProps?: any): [(opts?: any) => void, (data?: any, role?: string) => void];
+`;
+
+/**
+ * An in-memory React project with `react` and `@ionic/react` types installed.
+ * Entries in `files` are written as given, so a test can lay out components
+ * across files the way an app does.
+ */
+export function reactProject(files: Record): MigrationContext {
+ return createInMemoryContext({
+ 'package.json': JSON.stringify({ dependencies: { '@ionic/react': '^8.0.0', react: '^18.0.0' } }, null, 2),
+ 'node_modules/@types/react/package.json': JSON.stringify({ name: '@types/react', types: 'index.d.ts' }),
+ 'node_modules/@types/react/index.d.ts': REACT_TYPES,
+ 'node_modules/@ionic/react/package.json': JSON.stringify({ name: '@ionic/react', types: 'index.d.ts' }),
+ 'node_modules/@ionic/react/index.d.ts': IONIC_REACT_TYPES,
+ ...files,
+ });
+}
+
+/** The current text of a file in a {@link reactProject}, after a fix has run. */
+export function readSource(ctx: MigrationContext, relPath: string): string {
+ return ctx.project.getSourceFileOrThrow(`${ctx.rootDir}/${relPath}`).getFullText();
+}
diff --git a/packages/migrate/test/react-overlay-hook-props-manual.test.ts b/packages/migrate/test/react-overlay-hook-props-manual.test.ts
new file mode 100644
index 00000000000..3146f2f354c
--- /dev/null
+++ b/packages/migrate/test/react-overlay-hook-props-manual.test.ts
@@ -0,0 +1,468 @@
+import { describe, expect, it } from 'vitest';
+
+import { reactOverlayHookPropsManual as migration } from '../src/migrations/v9/react-overlay-hook-props-manual.js';
+import { reactProject } from './helpers/react.js';
+
+const BODY = `
+import React from 'react';
+export const Body: React.FC<{ title: string; count?: number }> = ({ title }) => null as any;
+`;
+
+/** A page whose single hook call is `body`, with `Body` in scope. */
+function page(body: string): Record {
+ return {
+ 'src/Body.tsx': BODY,
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+import { Body } from './Body';
+
+export const Page = () => {
+ ${body}
+ return null as any;
+};
+`,
+ };
+}
+
+describe('react-overlay-hook-props-manual', () => {
+ it('reports a prop the component does not declare', () => {
+ const ctx = reactProject({
+ 'src/Body.tsx': BODY,
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+import { Body } from './Body';
+
+export const Page = () => {
+ const [present] = useIonModal(Body, { titel: 'Hello' });
+ return null as any;
+};
+`,
+ });
+
+ const findings = migration.detect(ctx);
+
+ expect(findings).toEqual([
+ {
+ filePath: 'src/Page.tsx',
+ line: 6,
+ detail: 'Body has no "titel" prop. Did you mean "title"?',
+ },
+ ]);
+ });
+
+ it('reports a required prop componentProps leaves out', () => {
+ const ctx = reactProject(page(`const [present] = useIonModal(Body, { count: 1 });`));
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'Body requires "title", which componentProps does not pass',
+ ]);
+ });
+
+ it('collects every missing required prop into one finding per call', () => {
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import React from 'react';
+import { useIonModal } from '@ionic/react';
+
+const Body: React.FC<{ type: string; count: number; onIncrement: () => void }> = () => null as any;
+
+export const Page = () => {
+ const [present] = useIonModal(Body, {});
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'Body requires "type", "count" and "onIncrement", which componentProps does not pass',
+ ]);
+ });
+
+ it('reports a prop whose value does not match the declared type', () => {
+ const ctx = reactProject(page(`const [present] = useIonModal(Body, { title: 1 });`));
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'Body\'s "title" prop is string, and componentProps passes number',
+ ]);
+ });
+
+ it('says nothing about props passed to a component that declares none', () => {
+ // Verified against tsc: a props-less component's Props resolves to `{}`, and
+ // TypeScript skips excess-property checking against an empty target type, so
+ // this compiles. It looks wrong and is not.
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import { useIonPopover } from '@ionic/react';
+
+const Popover = () => null as any;
+
+export const Page = () => {
+ const [present, dismiss] = useIonPopover(Popover, { onDismiss: () => dismiss() });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('says nothing about an extra key on a componentProps variable', () => {
+ // Excess-property checking only applies to a fresh object literal. A variable
+ // is assignable as long as it satisfies the declared props.
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import React from 'react';
+import { useIonModal } from '@ionic/react';
+
+const Body: React.FC<{ title?: string }> = () => null as any;
+
+export const Page = () => {
+ const props = { title: 'Hello', nope: 1 };
+ const [present] = useIonModal(Body, props);
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('says nothing about an extra key that arrives through a spread', () => {
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import React from 'react';
+import { useIonModal } from '@ionic/react';
+
+const Body: React.FC<{ title?: string }> = () => null as any;
+
+export const Page = () => {
+ const extra = { title: 'Hello', nope: 1 };
+ const [present] = useIonModal(Body, { ...extra });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('reports a key written directly alongside a spread', () => {
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import React from 'react';
+import { useIonModal } from '@ionic/react';
+
+const Body: React.FC<{ title?: string }> = () => null as any;
+
+export const Page = () => {
+ const extra = { title: 'Hello' };
+ const [present] = useIonModal(Body, { nope: 1, ...extra });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual(['Body has no "nope" prop']);
+ });
+
+ it('reports a call that omits componentProps for a component that requires them', () => {
+ const ctx = reactProject(page(`const [present] = useIonModal(Body);`));
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'Body requires "title", so componentProps can no longer be omitted',
+ ]);
+ });
+
+ it('says nothing about a call that already type checks', () => {
+ const ctx = reactProject(page(`const [present] = useIonModal(Body, { title: 'Hello', count: 1 });`));
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('reports an inline component that reads a prop off an unannotated parameter', () => {
+ // Props are read from the component, so an unannotated parameter resolves to
+ // `{}` and reading a name off it is the error.
+ const ctx = reactProject(
+ page(`const [present] = useIonModal(({ name }) => name as any, { name: 'Dave' });`)
+ );
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'The inline component reads "name" off an unannotated props parameter, which resolves to {}',
+ ]);
+ });
+
+ it('reports an inline component reading props even when componentProps is empty', () => {
+ // The error is in the component body, so it does not depend on what is
+ // passed. Verified against tsc: this is TS2339 either way.
+ const ctx = reactProject(page(`const [present] = useIonModal(({ name }) => name as any, {});`));
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'The inline component reads "name" off an unannotated props parameter, which resolves to {}',
+ ]);
+ });
+
+ it('reports an inline component reading a prop through its parameter name', () => {
+ const ctx = reactProject(
+ page(`const [present] = useIonModal((props) => props.name as any, { name: 'Dave' });`)
+ );
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'The inline component reads "name" off an unannotated props parameter, which resolves to {}',
+ ]);
+ });
+
+ it('says nothing about an inline component that never reads its parameter', () => {
+ // Nothing is read off `{}`, so this compiles however much is passed.
+ const ctx = reactProject(page(`const [present] = useIonModal((props) => 'literal' as any, { anything: 1 });`));
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('does not count a shadowed parameter in a nested function as a read', () => {
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+
+export const Page = () => {
+ const [present] = useIonModal((props) => {
+ const inner = (props: { other: string }) => props.other;
+ return inner({ other: 'x' }) as any;
+ }, { anything: 1 });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('says nothing about an inline component that only collects a rest object', () => {
+ const ctx = reactProject(page(`const [present] = useIonModal(({ ...rest }) => rest as any, { anything: 1 });`));
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('checks against an explicit type argument rather than the component', () => {
+ const ctx = reactProject({
+ 'src/Body.tsx': BODY,
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+import { Body } from './Body';
+
+interface Pinned {
+ heading: string;
+}
+
+export const Page = () => {
+ const [present] = useIonModal(Body, { title: 'Hello' });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'Body has no "title" prop',
+ 'Body requires "heading", which componentProps does not pass',
+ ]);
+ });
+
+ it('follows a hook reached through a namespace import', () => {
+ const ctx = reactProject({
+ 'src/Body.tsx': BODY,
+ 'src/Page.tsx': `
+import * as Ionic from '@ionic/react';
+import { Body } from './Body';
+
+export const Page = () => {
+ const [present] = Ionic.useIonModal(Body, { titel: 'Hello' });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'Body has no "titel" prop. Did you mean "title"?',
+ ]);
+ });
+
+ it('follows a hook renamed on import', () => {
+ const ctx = reactProject({
+ 'src/Body.tsx': BODY,
+ 'src/Page.tsx': `
+import { useIonModal as useModal } from '@ionic/react';
+import { Body } from './Body';
+
+export const Page = () => {
+ const [present] = useModal(Body, { titel: 'Hello' });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'Body has no "titel" prop. Did you mean "title"?',
+ ]);
+ });
+
+ it("ignores an app's own function that shares a hook name", () => {
+ const ctx = reactProject({
+ 'src/Body.tsx': BODY,
+ 'src/Page.tsx': `
+import { Body } from './Body';
+
+function useIonModal(component: any, componentProps?: any) {
+ return [component, componentProps];
+}
+
+export const Page = () => {
+ const [present] = useIonModal(Body, { titel: 'Hello' });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('says nothing about a generic component whose prop types are not yet bound', () => {
+ // The `value` prop is declared as `T`, which nothing is assignable to until
+ // the hook instantiates it. Comparing against it would report a call that
+ // compiles.
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+
+function Generic({ value }: { value: T }) {
+ return value as any;
+}
+
+export const Page = () => {
+ const [present] = useIonModal(Generic, { value: 1 });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('still reports an unknown prop on a generic component', () => {
+ // Only the prop types are unbound. Which props exist is still known.
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+
+function Generic({ value }: { value: T }) {
+ return value as any;
+}
+
+export const Page = () => {
+ const [present] = useIonModal(Generic, { valeu: 1 });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'Generic has no "valeu" prop. Did you mean "value"?',
+ ]);
+ });
+
+ it('says nothing when componentProps is passed as any', () => {
+ // Passing `any` satisfies the signature, so the call still compiles, and there
+ // is nothing to read the passed keys from either.
+ const ctx = reactProject(page(`const [present] = useIonModal(Body, {} as any);`));
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('reads through a component that is not a plain reference', () => {
+ // A call expression, a conditional, and spread arguments all reach the
+ // analysis. None of them may throw, whatever the type resolves to.
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import React from 'react';
+import { useIonModal } from '@ionic/react';
+
+const Body: React.FC<{ title: string }> = () => null as any;
+const Other: React.FC<{ other: string }> = () => null as any;
+declare const flag: boolean;
+declare const args: unknown;
+declare function getBody(): React.FC<{ title: string }>;
+
+export const Page = () => {
+ const [a] = useIonModal(getBody(), { titel: 'x' });
+ const [b] = useIonModal(flag ? Body : Other, { title: 'x' });
+ const [c] = useIonModal(...(args as [any, any]));
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx).map((finding) => finding.detail)).toEqual([
+ 'The component has no "titel" prop. Did you mean "title"?',
+ ]);
+ });
+
+ it('says nothing about the JSX element form, whose props are already bound', () => {
+ const ctx = reactProject(
+ page(`const [present] = useIonModal(, { anything: true });`)
+ );
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('says nothing about a component typed with any, which stays permissive', () => {
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import React from 'react';
+import { useIonModal } from '@ionic/react';
+
+const Body: React.FC = () => null as any;
+
+export const Page = () => {
+ const [present] = useIonModal(Body, { anything: true });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('says nothing when the component type is out of reach', () => {
+ // A component from a package with no types installed. Reporting a guess here
+ // would be worse than the silence.
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+import { Body } from 'some-untyped-package';
+
+export const Page = () => {
+ const [present] = useIonModal(Body, { title: 'Hello' });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('says nothing about a component whose props are all optional', () => {
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import React from 'react';
+import { useIonModal } from '@ionic/react';
+
+const Body: React.FC<{ count?: number }> = () => null as any;
+
+export const Page = () => {
+ const [present] = useIonModal(Body);
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+});
diff --git a/packages/migrate/test/react-overlay-hook-props.test.ts b/packages/migrate/test/react-overlay-hook-props.test.ts
new file mode 100644
index 00000000000..6be5d17957c
--- /dev/null
+++ b/packages/migrate/test/react-overlay-hook-props.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from 'vitest';
+
+import { allMigrations } from '../src/migrations/index.js';
+import { reactOverlayHookProps as migration } from '../src/migrations/v9/react-overlay-hook-props.js';
+import { reactOverlayHookPropsManual } from '../src/migrations/v9/react-overlay-hook-props-manual.js';
+import { selectMigrations } from '../src/registry.js';
+import { readSource, reactProject } from './helpers/react.js';
+
+/** A page whose single hook call is `body`. */
+function page(body: string): Record {
+ return {
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+
+export const Page = () => {
+ ${body}
+ return null as any;
+};
+`,
+ };
+}
+
+describe('react-overlay-hook-props', () => {
+ it('annotates an inline component from the props it is passed', () => {
+ const ctx = reactProject(page(`const [present] = useIonModal(({ name }) => name as any, { name: 'Dave' });`));
+
+ migration.fix!(ctx);
+
+ expect(readSource(ctx, 'src/Page.tsx')).toContain(
+ `useIonModal(({ name }: { name: string }) => name as any, { name: 'Dave' })`
+ );
+ });
+
+ it('quotes a prop name that is not a valid identifier', () => {
+ const ctx = reactProject(
+ page(`const [present] = useIonModal(({ 'data-test': id }) => id as any, { 'data-test': 'x' });`)
+ );
+
+ migration.fix!(ctx);
+
+ expect(readSource(ctx, 'src/Page.tsx')).toContain(`({ 'data-test': id }: { 'data-test': string })`);
+ });
+
+ it('parenthesizes a bare parameter it annotates', () => {
+ const ctx = reactProject(page(`const [present] = useIonModal(props => props.name as any, { name: 'Dave' });`));
+
+ migration.fix!(ctx);
+
+ expect(readSource(ctx, 'src/Page.tsx')).toContain(
+ `useIonModal((props: { name: string }) => props.name as any, { name: 'Dave' })`
+ );
+ });
+
+ it('leaves nothing for the report-only migration to say about a call it fixed', () => {
+ const ctx = reactProject(page(`const [present] = useIonModal(({ name }) => name as any, { name: 'Dave' });`));
+
+ migration.fix!(ctx);
+
+ expect(reactOverlayHookPropsManual.detect(ctx)).toEqual([]);
+ });
+
+ it('runs before the report-only migration that covers the same calls', () => {
+ const selected = selectMigrations(allMigrations, {
+ fromMajor: 8,
+ toMajor: 9,
+ frameworks: ['react'],
+ includeExperimental: true,
+ }).map((selectedMigration) => selectedMigration.id);
+
+ expect(selected.indexOf(migration.id)).toBeLessThan(selected.indexOf(reactOverlayHookPropsManual.id));
+ });
+
+ it('declines a prop whose type is not in scope at the call', () => {
+ // The `Person` type is never imported here, so it prints as a module path and
+ // would not compile as an annotation.
+ const ctx = reactProject({
+ 'src/internal.ts': `export interface Person { name: string }`,
+ 'src/api.ts': `
+import type { Person } from './internal';
+export const person: Person = { name: 'Dave' };
+`,
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+import { person } from './api';
+
+export const Page = () => {
+ const [present] = useIonModal(({ owner }) => owner as any, { owner: person });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('declines when the props passed do not cover what the component reads', () => {
+ // Annotating from `{ other: 1 }` would leave `name` unreadable on the new
+ // type: a different compile error, and one the report would no longer see.
+ const ctx = reactProject(page(`const [present] = useIonModal(({ name }) => name as any, { other: 1 });`));
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('declines a call that pins Props with a type argument', () => {
+ // The type argument is what the hook checks against, so an annotation
+ // derived from componentProps could contradict it.
+ const ctx = reactProject({
+ 'src/Page.tsx': `
+import { useIonModal } from '@ionic/react';
+
+interface Pinned {
+ name: number;
+}
+
+export const Page = () => {
+ const [present] = useIonModal(({ name }) => name as any, { name: 'Dave' });
+ return null as any;
+};
+`,
+ });
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('declines an inline component that reads nothing', () => {
+ const ctx = reactProject(page(`const [present] = useIonModal((props) => 'literal' as any, { anything: 1 });`));
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+
+ it('declines props spread from elsewhere', () => {
+ const ctx = reactProject(
+ page(`
+ const extra = { name: 'Dave' };
+ const [present] = useIonModal(({ name }) => name as any, { ...extra });`)
+ );
+
+ expect(migration.detect(ctx)).toEqual([]);
+ });
+});
diff --git a/packages/migrate/test/react-typescript.test.ts b/packages/migrate/test/react-typescript.test.ts
new file mode 100644
index 00000000000..693e3a5a476
--- /dev/null
+++ b/packages/migrate/test/react-typescript.test.ts
@@ -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');
+ });
+});
diff --git a/packages/react/src/framework-delegate.tsx b/packages/react/src/framework-delegate.tsx
index 7379b4b97e0..e2952e9ba13 100644
--- a/packages/react/src/framework-delegate.tsx
+++ b/packages/react/src/framework-delegate.tsx
@@ -5,7 +5,7 @@ import { generateId } from './utils/generateId';
// TODO(FW-2959): types
-type ReactComponent = (props?: any) => JSX.Element;
+type ReactComponent = (props?: any) => React.ReactElement;
export const ReactDelegate = (
addView: (view: React.ReactElement) => void,
diff --git a/packages/react/src/hooks/__tests__/overlay-hook-types.spec.tsx b/packages/react/src/hooks/__tests__/overlay-hook-types.spec.tsx
new file mode 100644
index 00000000000..f2cbcd79aa8
--- /dev/null
+++ b/packages/react/src/hooks/__tests__/overlay-hook-types.spec.tsx
@@ -0,0 +1,160 @@
+import type {
+ ComponentClass,
+ FC,
+ ForwardRefExoticComponent,
+ MemoExoticComponent,
+ ReactElement,
+ 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;
+declare const RequiredClassComponent: ComponentClass;
+declare const RequiredMemoComponent: MemoExoticComponent>;
+declare const RequiredForwardRefComponent: ForwardRefExoticComponent>;
+declare const OptionalPropsComponent: FC;
+declare const NoPropsComponent: FC;
+declare const DismissableComponent: FC<{ dismiss: (data: string, role: string) => void }>;
+declare const UntypedComponent: FC;
+declare const overlayElement: ReactElement;
+
+// 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(RequiredFunctionComponent, { title: 'Modal' });
+ useIonPopoverSignature(RequiredFunctionComponent, { title: 'Popover' });
+
+ // @ts-expect-error an explicit type argument still checks the props
+ useIonModalSignature(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 }) => Hello {name}.
, { name: 'Dave' });
+ useIonPopoverSignature(({ name }: { name: string }) => Hello {name}.
, { 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.
+ });
+});
diff --git a/packages/react/src/hooks/useIonModal.ts b/packages/react/src/hooks/useIonModal.ts
index aee3ea40e40..faa58df031b 100644
--- a/packages/react/src/hooks/useIonModal.ts
+++ b/packages/react/src/hooks/useIonModal.ts
@@ -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, ReactElement } from 'react';
import { useCallback } from 'react';
import type { ReactComponentOrElement } from '../models/ReactComponentOrElement';
@@ -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(
+ ...args: {} extends Props
+ ? [component: ComponentType, componentProps?: NoInfer]
+ : [component: ComponentType, componentProps: NoInfer]
+): 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: ReactElement, componentProps?: any): UseIonModalResult;
export function useIonModal(component: ReactComponentOrElement, componentProps?: any): UseIonModalResult {
const controller = useOverlay(
'IonModal',
diff --git a/packages/react/src/hooks/useIonPopover.ts b/packages/react/src/hooks/useIonPopover.ts
index c63df7b26e0..9ddd8b21609 100644
--- a/packages/react/src/hooks/useIonPopover.ts
+++ b/packages/react/src/hooks/useIonPopover.ts
@@ -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, ReactElement } from 'react';
import { useCallback } from 'react';
import type { ReactComponentOrElement } from '../models/ReactComponentOrElement';
@@ -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(
+ ...args: {} extends Props
+ ? [component: ComponentType, componentProps?: NoInfer]
+ : [component: ComponentType, componentProps: NoInfer]
+): 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: ReactElement, componentProps?: any): UseIonPopoverResult;
export function useIonPopover(component: ReactComponentOrElement, componentProps?: any): UseIonPopoverResult {
const controller = useOverlay(
'IonPopover',
diff --git a/packages/react/src/models/ReactComponentOrElement.ts b/packages/react/src/models/ReactComponentOrElement.ts
index 2cb796ed837..798042c8c21 100644
--- a/packages/react/src/models/ReactComponentOrElement.ts
+++ b/packages/react/src/models/ReactComponentOrElement.ts
@@ -1,3 +1,3 @@
import type React from 'react';
-export type ReactComponentOrElement = React.ComponentClass | React.FC | JSX.Element;
+export type ReactComponentOrElement = React.ComponentClass | React.FC | React.ReactElement;
diff --git a/packages/react/test/base/src/pages/overlay-hooks/ModalHook.tsx b/packages/react/test/base/src/pages/overlay-hooks/ModalHook.tsx
index fe3dfd0c284..f581d3e506a 100644
--- a/packages/react/test/base/src/pages/overlay-hooks/ModalHook.tsx
+++ b/packages/react/test/base/src/pages/overlay-hooks/ModalHook.tsx
@@ -51,7 +51,7 @@ const ModalHook: React.FC = () => {
setCount(count + 1);
}, [count, setCount]);
- const handleDismissWithComponent = useCallback((data: any, role: string) => {
+ const handleDismissWithComponent = useCallback((data?: any, role?: string) => {
dismissWithComponent(data, role);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -87,6 +87,9 @@ const ModalHook: React.FC = () => {
const [presentSecondaryModal] = useIonModal(ModalSecondary);
const [presentRootModal, dismissRootModal] = useIonModal(Body, {
+ type: 'Root',
+ count: count,
+ onIncrement: handleIncrement,
onDismiss: () => {
dismissRootModal();
presentSecondaryModal();