Skip to content
Merged
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
1 change: 1 addition & 0 deletions core/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export interface IonicConfig {
* - `'OFF'`: No errors or warnings are logged.
* - `'ERROR'`: Logs only errors.
* - `'WARN'`: Logs errors and warnings.
* - `'DEBUG'`: Logs errors, warnings, and Ionic's internal diagnostics.
*/
logLevel?: LogLevel;

Expand Down
29 changes: 25 additions & 4 deletions core/src/utils/logging/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,39 @@ export enum LogLevel {
OFF = 'OFF',
ERROR = 'ERROR',
WARN = 'WARN',
DEBUG = 'DEBUG',
}

/**
* Ranks each level so an enabled check is a numeric comparison. A configured
* level logs anything whose rank is less than or equal to its own: `OFF` (0)
* logs nothing, `ERROR` (1) logs errors, `WARN` (2) logs errors and warnings,
* `DEBUG` (3) logs all of the above plus internal diagnostics.
*/
const LOG_LEVEL_RANK: Record<LogLevel, number> = {
[LogLevel.OFF]: 0,
[LogLevel.ERROR]: 1,
[LogLevel.WARN]: 2,
[LogLevel.DEBUG]: 3,
};

/**
* Whether the configured level is verbose enough to log `minimum`. Levels set
* through a query parameter arrive as raw strings, hence the uppercasing.
*/
const isLogLevelEnabled = (minimum: LogLevel): boolean => {
const configured = String(config.get('logLevel', LogLevel.WARN)).toUpperCase() as LogLevel;
return LOG_LEVEL_RANK[configured] >= LOG_LEVEL_RANK[minimum];
};

/**
* Logs a warning to the console with an Ionic prefix
* to indicate the library that is warning the developer.
*
* @param message - The string message to be logged to the console.
*/
export const printIonWarning = (message: string, ...params: any[]) => {
const logLevel = config.get('logLevel', LogLevel.WARN);
if ([LogLevel.WARN].includes(logLevel)) {
if (isLogLevelEnabled(LogLevel.WARN)) {
return console.warn(`[Ionic Warning]: ${message}`, ...params);
}
};
Expand All @@ -27,8 +49,7 @@ export const printIonWarning = (message: string, ...params: any[]) => {
* @param params - Additional arguments to supply to the console.error.
*/
export const printIonError = (message: string, ...params: any[]) => {
const logLevel = config.get('logLevel', LogLevel.ERROR);
if ([LogLevel.ERROR, LogLevel.WARN].includes(logLevel)) {
if (isLogLevelEnabled(LogLevel.ERROR)) {
return console.error(`[Ionic Error]: ${message}`, ...params);
}
};
Expand Down
20 changes: 20 additions & 0 deletions core/src/utils/logging/test/logging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ describe('Logging', () => {
});
});

describe("when the logLevel configuration is set to 'DEBUG'", () => {
it('logs a warning to the console', () => {
config.set('logLevel', LogLevel.DEBUG);

printIonWarning('This is a warning message');

expect(consoleWarnSpy).toHaveBeenCalledWith('[Ionic Warning]: This is a warning message');
});
});

describe("when the logLevel configuration is set to 'ERROR'", () => {
it('does not log a warning to the console', () => {
config.set('logLevel', LogLevel.ERROR);
Expand Down Expand Up @@ -101,6 +111,16 @@ describe('Logging', () => {
});
});

describe("when the logLevel configuration is set to 'DEBUG'", () => {
it('logs an error to the console', () => {
config.set('logLevel', LogLevel.DEBUG);

printIonError('This is an error message');

expect(consoleErrorSpy).toHaveBeenCalledWith('[Ionic Error]: This is an error message');
});
});

describe("when the logLevel configuration is set to 'OFF'", () => {
it('does not log an error to the console', () => {
config.set('logLevel', LogLevel.OFF);
Expand Down
18 changes: 18 additions & 0 deletions docs/react-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,21 @@ See our [Contributing Guide](/docs/CONTRIBUTING.md).
## Testing

Refer to the [React Router Testing documentation](./testing.md) for testing the React Router package.

## Debug Logging

The `StackManager` logs the decisions behind the swipe-to-go-back gesture: whether it can start, which views are entering and leaving, and whether the entering page ends up visible. These logs are off in every build, dev included. Ionic's `logLevel` config turns them on, either through the URL:

```
http://localhost:3000/routing?ionic:logLevel=DEBUG
```

or before the app renders:

```tsx
import { LogLevel, setupIonicReact } from '@ionic/react';

setupIonicReact({ logLevel: LogLevel.DEBUG });
```

Refer to [the testing docs](./testing.md#debug-logging-in-e2e-runs) for how to read them in a failing e2e run.
9 changes: 9 additions & 0 deletions docs/react-router/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ Useful flags:
| `--app <name>` | Pick a different app variant from `packages/react-router/test/apps/` (default: `reactrouter6-react18`; use `reactrouter6-react19` for the latest supported React version) |
| `--serve` | Start the dev server only and open the browser |

## Debug Logging in E2E Runs

The test app starts with `setupIonicReact({ logLevel: LogLevel.DEBUG })`, so the `StackManager` swipe-back diagnostics are on for every spec.

- Cypress prints the browser console to the terminal on failure, via `cypress-terminal-report`.
- Playwright records a trace on the first retry, so CI failures come with one. Open it with `npx playwright show-trace <path>` and read the console tab. Retries are off locally, so pass `--trace on` when you want the same thing from a local run. Don't turn tracing on by default: the recording overhead is enough to destabilize the tab lifecycle specs on React 19.

A passing run collects the same logs in the browser and throws them away, so nothing reaches your terminal. Refer to [Debug Logging](./README.md#debug-logging) for turning them on in your own app.

## Test App Build Structure

Unlike other test applications, these test apps are broken up into multiple directories. These directories are then combined to create a single application. This allows us to share common application code, tests, etc so that each app is being tested the same way. Below details the different pieces that help create a single test application.
Expand Down
109 changes: 32 additions & 77 deletions packages/react-router/src/ReactRouter/StackManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react';
import { IonRoute, RouteManagerContext, StackContext, generateId } from '@ionic/react';
import { IonRoute, RouteManagerContext, StackContext, createDebugLogger, generateId } from '@ionic/react';
import React from 'react';
import type { RouteObject } from 'react-router-dom';
import { Route, UNSAFE_RouteContext as RouteContext, matchRoutes } from 'react-router-dom';
Expand Down Expand Up @@ -35,6 +35,9 @@ const VIEW_UNMOUNT_DELAY_MS = 250;
*/
const ION_PAGE_WAIT_TIMEOUT_MS = 300;

/** Off unless the app sets `logLevel: 'DEBUG'`. */
const debug = createDebugLogger('react-router');

interface StackManagerProps {
routeInfo: RouteInfo;
id?: string;
Expand All @@ -47,19 +50,6 @@ const isViewVisible = (el: HTMLElement) =>

const hideIonPageElement = (element: HTMLElement | undefined): void => {
if (element) {
if (element.id === 'section-a' || element.id === 'section-b') {
// eslint-disable-next-line no-console
console.log(
'[HideIonPageElement]',
JSON.stringify({
id: element.id,
stack: new Error().stack
?.split('\n')
.slice(1, 6)
.map((s) => s.trim()),
})
);
}
element.classList.add('ion-page-hidden');
element.setAttribute('aria-hidden', 'true');
}
Expand Down Expand Up @@ -91,32 +81,9 @@ const showIonPageElement = (element: HTMLElement | undefined): void => {
*/
const revealIonPageForSwipeBack = (element: HTMLElement | undefined): void => {
if (element) {
const before = {
id: element.id,
inlineDisplay: element.style.display,
hasHiddenClass: element.classList.contains('ion-page-hidden'),
ariaHidden: element.getAttribute('aria-hidden'),
computedDisplay: getComputedStyle(element).display,
};
element.style.removeProperty('display');
element.classList.remove('ion-page-hidden');
element.removeAttribute('aria-hidden');
// eslint-disable-next-line no-console
console.log(
'[SwipeBackReveal]',
JSON.stringify({
before,
after: {
inlineDisplay: element.style.display,
hasHiddenClass: element.classList.contains('ion-page-hidden'),
ariaHidden: element.getAttribute('aria-hidden'),
computedDisplay: getComputedStyle(element).display,
},
})
);
} else {
// eslint-disable-next-line no-console
console.log('[SwipeBackReveal] element is undefined');
}
};

Expand Down Expand Up @@ -1433,20 +1400,16 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname &&
enteringViewItem.routeData.match.pathname !== routeInfo.pathname;

// eslint-disable-next-line no-console
console.log(
'[SwipeBackCanStart]',
JSON.stringify({
outletId: this.id,
routePathname: routeInfo.pathname,
swipeBackPathname: swipeBackRouteInfo?.pathname,
enteringViewId: enteringViewItem?.id,
enteringViewPath: enteringViewItem?.reactElement?.props?.path,
enteringMount: enteringViewItem?.mount,
ionPageInDocument,
canStartSwipe,
})
);
debug('SwipeBackCanStart', () => ({
outletId: this.id,
routePathname: routeInfo.pathname,
swipeBackPathname: swipeBackRouteInfo?.pathname,
enteringViewId: enteringViewItem?.id,
enteringViewPath: enteringViewItem?.reactElement?.props?.path,
enteringMount: enteringViewItem?.mount,
ionPageInDocument,
canStartSwipe,
}));

return canStartSwipe;
};
Expand All @@ -1457,20 +1420,16 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);

// eslint-disable-next-line no-console
console.log(
'[SwipeBackOnStart:entry]',
JSON.stringify({
outletId: this.id,
routePathname: routeInfo.pathname,
swipeBackPathname: swipeBackRouteInfo?.pathname,
enteringViewId: enteringViewItem?.id,
enteringViewPath: enteringViewItem?.reactElement?.props?.path,
enteringMount: enteringViewItem?.mount,
hasEnteringIonPageElement: !!enteringViewItem?.ionPageElement,
leavingViewId: leavingViewItem?.id,
})
);
debug('SwipeBackOnStart:entry', () => ({
outletId: this.id,
routePathname: routeInfo.pathname,
swipeBackPathname: swipeBackRouteInfo?.pathname,
enteringViewId: enteringViewItem?.id,
enteringViewPath: enteringViewItem?.reactElement?.props?.path,
enteringMount: enteringViewItem?.mount,
hasEnteringIonPageElement: !!enteringViewItem?.ionPageElement,
leavingViewId: leavingViewItem?.id,
}));

// Ensure the entering view is mounted so React keeps rendering it during the gesture.
// This is important when the view was previously marked for unmount but its
Expand All @@ -1489,18 +1448,14 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
}

// eslint-disable-next-line no-console
console.log(
'[SwipeBackOnStart:exit]',
JSON.stringify({
outletId: this.id,
enteringFinalComputedDisplay: enteringViewItem?.ionPageElement
? getComputedStyle(enteringViewItem.ionPageElement).display
: null,
enteringFinalInlineDisplay: enteringViewItem?.ionPageElement?.style.display ?? null,
enteringFinalHiddenClass: enteringViewItem?.ionPageElement?.classList.contains('ion-page-hidden') ?? null,
})
);
debug('SwipeBackOnStart:exit', () => ({
outletId: this.id,
enteringFinalComputedDisplay: enteringViewItem?.ionPageElement
? getComputedStyle(enteringViewItem.ionPageElement).display
: null,
enteringFinalInlineDisplay: enteringViewItem?.ionPageElement?.style.display ?? null,
enteringFinalHiddenClass: enteringViewItem?.ionPageElement?.classList.contains('ion-page-hidden') ?? null,
}));

return Promise.resolve();
};
Expand Down
5 changes: 3 additions & 2 deletions packages/react-router/test/base/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IonApp, setupIonicReact, IonRouterOutlet } from '@ionic/react';
import { IonApp, setupIonicReact, LogLevel, IonRouterOutlet } from '@ionic/react';
import React from 'react';
import { Route, Navigate } from 'react-router-dom';

Expand Down Expand Up @@ -72,7 +72,8 @@ import SuspenseOutlet from './pages/suspense-outlet/SuspenseOutlet';
import { PropsUpdateDirect, PropsUpdateRoutesWrapper } from './pages/props-update/PropsUpdate';
import DisabledButton from './pages/disabled-button/DisabledButton';

setupIonicReact();
// Debug logs on so failing specs include the navigation diagnostics.
setupIonicReact({ logLevel: LogLevel.DEBUG });

const App: React.FC = () => {
return (
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/components/IonIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react';

import { NavContext } from '../contexts/NavContext';
import { getConfig } from '../utils/config';

import type { IonicReactProps } from './IonicReactProps';
import { IonIconInner } from './inner-proxies';
import { createForwardRef, getConfig } from './utils';
import { createForwardRef } from './utils';

interface IonIconProps {
color?: string;
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
getTimeGivenProgression,
getIonPageElement,
openURL,
LogLevel,

// TYPES
Animation,
Expand Down Expand Up @@ -124,7 +125,8 @@ export * from './IonRoute';
export * from './IonRouterContext';

// Utils
export { isPlatform, getPlatforms, getConfig } from './utils';
export { isPlatform, getPlatforms } from './utils';
export { getConfig } from '../utils/config';
export * from './hrefprops';

// Ionic Animations
Expand Down
12 changes: 1 addition & 11 deletions packages/react/src/components/utils/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Config as CoreConfig, Platforms } from '@ionic/core/components';
import type { Platforms } from '@ionic/core/components';
import { getPlatforms as getPlatformsCore, isPlatform as isPlatformCore } from '@ionic/core/components';
import React from 'react';

Expand Down Expand Up @@ -39,13 +39,3 @@ export const isPlatform = (platform: Platforms) => {
export const getPlatforms = () => {
return getPlatformsCore(window);
};

export const getConfig = (): CoreConfig | null => {
if (typeof (window as any) !== 'undefined') {
const Ionic = (window as any).Ionic;
if (Ionic && Ionic.config) {
return Ionic.config;
}
}
return null;
};
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './components';
export * from './routing';
export * from './models';
export * from './utils/generateId';
export * from './utils/debug';
Loading
Loading