feat(react): fixing support for react 19, adding test app for react 19 (#30217)

Issue number: resolves #29991

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
This commit is contained in:
Shane
2025-03-03 08:50:05 -08:00
committed by GitHub
parent 521d077376
commit f4941f2639
20 changed files with 11057 additions and 288 deletions

View File

@ -198,7 +198,7 @@ jobs:
strategy:
fail-fast: false
matrix:
apps: [react17, react18]
apps: [react17, react18, react19]
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:

View File

@ -208,7 +208,7 @@ jobs:
strategy:
fail-fast: false
matrix:
apps: [react17, react18]
apps: [react17, react18, react19]
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:

View File

@ -1,5 +1,5 @@
import type { JSX as LocalJSX } from '@ionic/core/components';
import React from 'react';
import React, { type PropsWithChildren } from 'react';
import type { IonContextInterface } from '../contexts/IonContext';
import { IonContext } from '../contexts/IonContext';
@ -9,13 +9,14 @@ import { IonOverlayManager } from './IonOverlayManager';
import type { IonicReactProps } from './IonicReactProps';
import { IonAppInner } from './inner-proxies';
type Props = LocalJSX.IonApp &
type Props = PropsWithChildren<
LocalJSX.IonApp &
IonicReactProps & {
ref?: React.Ref<HTMLIonAppElement>;
};
}
>;
export const IonApp = /*@__PURE__*/ (() =>
class extends React.Component<Props> {
export class IonApp extends React.Component<Props> {
addOverlayCallback?: (id: string, overlay: ReactComponentOrElement, containerElement: HTMLDivElement) => void;
removeOverlayCallback?: (id: string) => void;
@ -23,9 +24,6 @@ export const IonApp = /*@__PURE__*/ (() =>
super(props);
}
/*
Wire up methods to call into IonOverlayManager
*/
ionContext: IonContextInterface = {
addOverlay: (id: string, overlay: ReactComponentOrElement, containerElement: HTMLDivElement) => {
if (this.addOverlayCallback) {
@ -55,7 +53,5 @@ export const IonApp = /*@__PURE__*/ (() =>
);
}
static get displayName() {
return 'IonApp';
}
})();
static displayName = 'IonApp';
}

View File

@ -1,23 +1,18 @@
import type { JSX as LocalJSX } from '@ionic/core/components';
import React from 'react';
import React, { type PropsWithChildren } from 'react';
import { NavContext } from '../../contexts/NavContext';
import type { IonicReactProps } from '../IonicReactProps';
import { IonBackButtonInner } from '../inner-proxies';
type Props = Omit<LocalJSX.IonBackButton, 'icon'> &
type Props = PropsWithChildren<
LocalJSX.IonBackButton &
IonicReactProps & {
icon?:
| {
ios: string;
md: string;
}
| string;
ref?: React.Ref<HTMLIonBackButtonElement>;
};
}
>;
export const IonBackButton = /*@__PURE__*/ (() =>
class extends React.Component<Props> {
export class IonBackButton extends React.Component<Props> {
context!: React.ContextType<typeof NavContext>;
clickButton = (e: React.MouseEvent) => {
@ -51,4 +46,8 @@ export const IonBackButton = /*@__PURE__*/ (() =>
static get contextType() {
return NavContext;
}
})();
shouldComponentUpdate(): boolean {
return true;
}
}

View File

@ -13,10 +13,14 @@ type Props = LocalJSX.IonTabButton &
onPointerDown?: React.PointerEventHandler<HTMLIonTabButtonElement>;
onTouchEnd?: React.TouchEventHandler<HTMLIonTabButtonElement>;
onTouchMove?: React.TouchEventHandler<HTMLIonTabButtonElement>;
children?: React.ReactNode;
};
export const IonTabButton = /*@__PURE__*/ (() =>
class extends React.Component<Props> {
export class IonTabButton extends React.Component<Props> {
shouldComponentUpdate(): boolean {
return true;
}
constructor(props: Props) {
super(props);
this.handleIonTabButtonClick = this.handleIonTabButtonClick.bind(this);
@ -50,4 +54,4 @@ export const IonTabButton = /*@__PURE__*/ (() =>
static get displayName() {
return 'IonTabButton';
}
})();
}

View File

@ -1,3 +1,4 @@
import type { Components } from '@ionic/core';
import type { JSX as LocalJSX } from '@ionic/core/components';
import React, { Fragment } from 'react';
@ -26,12 +27,14 @@ if (typeof (window as any) !== 'undefined' && window.customElements) {
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
interface IntrinsicElements {
'ion-tabs': any;
}
export interface IonTabsProps extends React.HTMLAttributes<Components.IonTabs> {
onIonTabsWillChange?: (event: CustomEvent<{ tab: string }>) => void;
onIonTabsDidChange?: (event: CustomEvent<{ tab: string }>) => void;
}
declare module 'react' {
interface HTMLElements {
'ion-tabs': IonTabsProps;
}
}
@ -40,10 +43,15 @@ type ChildFunction = (ionTabContext: IonTabsContextState) => React.ReactNode;
interface Props extends LocalJSX.IonTabs {
className?: string;
children: ChildFunction | React.ReactNode;
onIonTabsWillChange?: (event: CustomEvent<{ tab: string }>) => void;
onIonTabsDidChange?: (event: CustomEvent<{ tab: string }>) => void;
}
export const IonTabs = /*@__PURE__*/ (() =>
class extends React.Component<Props> {
export class IonTabs extends React.Component<Props> {
shouldComponentUpdate(): boolean {
return true;
}
context!: React.ContextType<typeof NavContext>;
/**
* `routerOutletRef` allows users to add a `ref` to `IonRouterOutlet`.
@ -51,7 +59,7 @@ export const IonTabs = /*@__PURE__*/ (() =>
* breaking their ability to access the `IonRouterOutlet` instance.
* Do not remove this ref.
*/
routerOutletRef: React.Ref<HTMLIonRouterOutletElement> = React.createRef();
routerOutletRef: React.Ref<Components.IonRouterOutlet> = React.createRef();
selectTabHandler?: (tag: string) => boolean;
tabBarRef = React.createRef<any>();
@ -205,4 +213,4 @@ export const IonTabs = /*@__PURE__*/ (() =>
static get contextType() {
return NavContext;
}
})();
}

View File

@ -0,0 +1,14 @@
import { IonButton } from '@ionic/react';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
test('should support onDoubleClick bindings', () => {
const mockFn = jest.fn();
render(<IonButton onDoubleClick={mockFn}>Click me</IonButton>);
// Simulate a double click on the button
fireEvent.dblClick(screen.getByText('Click me'));
expect(mockFn).toBeCalled();
});

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Ionic App</title>
<base href="/" />
<meta name="color-scheme" content="light dark" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<link rel="manifest" href="/manifest.json" />
<link rel="shortcut icon" type="image/png" href="/favicon.png" />
<!-- add to homescreen for ios -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Ionic App" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
{
"name": "test-app",
"version": "0.0.1",
"private": true,
"dependencies": {
"@ionic/react": "^8.4.0",
"@ionic/react-router": "^8.4.0",
"ionicons": "^7.4.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-router": "^5.3.4",
"react-router-dom": "^5.3.4"
},
"scripts": {
"dev": "vite",
"start": "vite",
"build": "tsc && vite build",
"test": "vitest",
"sync": "sh ./scripts/sync.sh",
"cypress": "cypress run --headless --browser chrome",
"cypress.open": "cypress open",
"e2e": "concurrently \"serve -s dist -l 3000\" \"wait-on http-get://localhost:3000 && npm run cypress\" --kill-others --success first"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.4.3",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-legacy": "^4.0.2",
"@vitejs/plugin-react": "^4.0.1",
"concurrently": "^6.3.0",
"cypress": "^13.2.0",
"eslint": "^8.35.0",
"eslint-plugin-react": "^7.32.2",
"jsdom": "^22.1.0",
"serve": "^14.0.1",
"typescript": "^5.1.6",
"vite": "^4.3.9",
"vitest": "^0.32.2",
"wait-on": "^6.0.0"
},
"description": "An Ionic project",
"engines": {
"node": ">= 16"
}
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,19 @@
import legacy from '@vitejs/plugin-legacy'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
legacy()
],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
},
server: {
port: 3000
}
})

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Route } from 'react-router-dom';
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import React from 'react';
import { Route } from 'react-router-dom';
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
@ -21,19 +21,19 @@ import '@ionic/react/css/display.css';
/* Theme variables */
import './theme/variables.css';
import Icons from './pages/Icons';
import Main from './pages/Main';
import OverlayHooks from './pages/overlay-hooks/OverlayHooks';
import OverlayComponents from './pages/overlay-components/OverlayComponents';
import KeepContentsMounted from './pages/overlay-components/KeepContentsMounted';
import Tabs from './pages/Tabs';
import TabsBasic from './pages/TabsBasic';
import Icons from './pages/Icons';
import NavComponent from './pages/navigation/NavComponent';
import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling';
import IonModalConditional from './pages/overlay-components/IonModalConditional';
import IonModalConditionalSibling from './pages/overlay-components/IonModalConditionalSibling';
import IonModalDatetimeButton from './pages/overlay-components/IonModalDatetimeButton';
import IonPopoverNested from './pages/overlay-components/IonPopoverNested';
import IonModalMultipleChildren from './pages/overlay-components/IonModalMultipleChildren';
import IonPopoverNested from './pages/overlay-components/IonPopoverNested';
import KeepContentsMounted from './pages/overlay-components/KeepContentsMounted';
import OverlayComponents from './pages/overlay-components/OverlayComponents';
import OverlayHooks from './pages/overlay-hooks/OverlayHooks';
setupIonicReact();

View File

@ -1,9 +1,9 @@
import { IonButton } from '@ionic/react';
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { vi, test, expect } from 'vitest';
test('should support onDoubleClick bindings', () => {
const mockFn = jest.fn();
const mockFn = vi.fn();
render(<IonButton onDoubleClick={mockFn}>Click me</IonButton>);

View File

@ -98,7 +98,7 @@ const PageThree = ({ nav }: { nav: React.MutableRefObject<HTMLIonNavElement> })
};
const NavComponent: React.FC = () => {
const ref = useRef<any>();
const ref = useRef<any>(null);
return (
<IonPage>
<IonNav

View File

@ -7,7 +7,11 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"esModuleInterop": true,
"lib": ["dom", "es2015"],
"lib": [
"dom",
"es2020",
"dom.iterable"
],
"importHelpers": true,
"module": "esnext",
"moduleResolution": "bundler",
@ -20,8 +24,8 @@
"removeComments": false,
"inlineSources": true,
"sourceMap": true,
"jsx": "react",
"target": "es2017"
"jsx": "react-jsx",
"target": "es2020",
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"compileOnSave": false,

View File

@ -7,9 +7,10 @@
"declaration": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"importHelpers": true,
"lib": [
"dom",
"es2017"
"es2017",
],
"module": "es2015",
"moduleResolution": "node",