Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/platform-user-agent-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-simplikit': patch
---

Prefer `navigator.userAgentData` when detecting iOS and Android platforms
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

`isAndroid` is a utility function that detects whether the current device is running Android.

Detection prefers the User-Agent Client Hints API (`navigator.userAgentData.platform`)
and falls back to matching `"Android"` in Client Hints brands or `navigator.userAgent`.

Notes:

- All Android browsers include the token "Android" in the user agent.
- Chromium on Android typically exposes Client Hints `platform` as `"Android"`.

## Interface

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

`isIOS` is a utility function that detects whether the current device is running iOS or iPadOS.

Detection prefers the User-Agent Client Hints API (`navigator.userAgentData.platform`)
and falls back to the legacy `navigator.platform` / `navigator.userAgent` strings.

Notes on platform inconsistencies:

- Prior to iPadOS 13, iPads reported their platform as "iPad" (or matched /iPad/ in UA).
- Starting from iPadOS 13, Apple changed the platform string to "MacIntel"
to make websites treat iPadOS as desktop-class Safari.
However, these devices still expose multi-touch capabilities.
- Chromium-based browsers may report Client Hints `platform` as `"iOS"` instead of `"iPhone"`.

## Interface

Expand All @@ -20,7 +24,7 @@ function isIOS(userAgent?: string): boolean;
<Interface
name="userAgent"
type="string"
description="Optional user agent string to check. Defaults to <code>navigator.userAgent</code>."
description="Optional user agent string to check. Defaults to <code>navigator.userAgent</code>. When provided, the string is matched directly; the modern iPadOS heuristic still uses the live platform and touch APIs so desktop-class iPad Safari can be detected."
/>

### Return Value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from 'vitest';

import { serverEnvironments } from '../test-utils/serverEnvironments.ts';

import { testPlatform } from './testPlatform.ts';
import type { NavigatorUAData } from './types.ts';

describe('testPlatform', () => {
const originalPlatform = navigator.platform;
const originalDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgentData');

afterEach(function restoreNavigator() {
Object.defineProperty(navigator, 'platform', {
value: originalPlatform,
configurable: true,
});

if (originalDescriptor == null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cleanup test stub
delete (navigator as any).userAgentData;
return;
}

Object.defineProperty(Navigator.prototype, 'userAgentData', originalDescriptor);
});

function stubUserAgentData(userAgentData: NavigatorUAData | undefined) {
Object.defineProperty(navigator, 'userAgentData', {
value: userAgentData,
configurable: true,
});
}

it('prefers userAgentData.platform over navigator.platform', () => {
stubUserAgentData({
brands: [{ brand: 'Chromium', version: '120' }],
mobile: true,
platform: 'Android',
});
Object.defineProperty(navigator, 'platform', {
value: 'Linux armv8l',
configurable: true,
});

expect(testPlatform(/^Android/i)).toBe(true);
expect(testPlatform(/^Linux/i)).toBe(false);
});

it('falls back to navigator.platform when userAgentData is missing', () => {
stubUserAgentData(undefined);
Object.defineProperty(navigator, 'platform', {
value: 'iPhone',
configurable: true,
});

expect(testPlatform(/^iPhone/i)).toBe(true);
});

it('matches iOS Client Hints platform', () => {
stubUserAgentData({
brands: [{ brand: 'Chromium', version: '120' }],
mobile: true,
platform: 'iOS',
});
Object.defineProperty(navigator, 'platform', {
value: 'iPhone',
configurable: true,
});

expect(testPlatform(/^(iPhone|iPad|iPod|iOS)/i)).toBe(true);
});

it('returns false when the platform does not match', () => {
stubUserAgentData({
brands: [{ brand: 'Chromium', version: '120' }],
mobile: false,
platform: 'Windows',
});

expect(testPlatform(/^Android/i)).toBe(false);
});

it('returns false on the server', () => {
const result = serverEnvironments(() => testPlatform(/^Android/i));

expect(result).toBe(false);
});
});
16 changes: 16 additions & 0 deletions packages/react-simplikit/src/_internal/platform/testPlatform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { NavigatorWithUserAgentData } from './types.ts';

/**
* Tests a regular expression against `navigator.userAgentData.platform` when
* available, otherwise against the legacy `navigator.platform` string.
*/
export function testPlatform(re: RegExp): boolean {
if (typeof window === 'undefined') {
return false;
}

const navigatorWithHints = window.navigator as NavigatorWithUserAgentData;
const platform = navigatorWithHints.userAgentData?.platform ?? window.navigator.platform;

return re.test(platform);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it } from 'vitest';

import { serverEnvironments } from '../test-utils/serverEnvironments.ts';

import { testUserAgent } from './testUserAgent.ts';
import type { NavigatorUAData } from './types.ts';

describe('testUserAgent', () => {
const originalUserAgent = navigator.userAgent;
const originalDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgentData');

afterEach(function restoreNavigator() {
Object.defineProperty(navigator, 'userAgent', {
value: originalUserAgent,
configurable: true,
});

if (originalDescriptor == null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- cleanup test stub
delete (navigator as any).userAgentData;
return;
}

Object.defineProperty(Navigator.prototype, 'userAgentData', originalDescriptor);
});

function stubUserAgentData(userAgentData: NavigatorUAData | undefined) {
Object.defineProperty(navigator, 'userAgentData', {
value: userAgentData,
configurable: true,
});
}

it('returns true when a Client Hints brand matches', () => {
stubUserAgentData({
brands: [
{ brand: 'Chromium', version: '120' },
{ brand: 'Google Chrome', version: '120' },
],
mobile: false,
platform: 'Windows',
});
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
configurable: true,
});

expect(testUserAgent(/Chrome/i)).toBe(true);
});

it('falls back to navigator.userAgent when brands do not match', () => {
stubUserAgentData({
brands: [{ brand: 'Chromium', version: '120' }],
mobile: true,
platform: 'Android',
});
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/126',
configurable: true,
});

expect(testUserAgent(/Android/i)).toBe(true);
});

it('falls back to navigator.userAgent when userAgentData is missing', () => {
stubUserAgentData(undefined);
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Chrome/126',
configurable: true,
});

expect(testUserAgent(/Android/i)).toBe(true);
});

it('returns false when neither brands nor userAgent match', () => {
stubUserAgentData({
brands: [{ brand: 'Chromium', version: '120' }],
mobile: false,
platform: 'Windows',
});
Object.defineProperty(navigator, 'userAgent', {
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
configurable: true,
});

expect(testUserAgent(/Android/i)).toBe(false);
});

it('returns false on the server', () => {
const result = serverEnvironments(() => testUserAgent(/Android/i));

expect(result).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { NavigatorWithUserAgentData } from './types.ts';

/**
* Tests a regular expression against Client Hints brand list when available,
* otherwise against `navigator.userAgent`.
*
* Brand matching is useful for browser engines (Chrome, Firefox, …).
* Platform tokens such as "Android" usually appear only in the UA string.
*/
export function testUserAgent(re: RegExp): boolean {
if (typeof window === 'undefined') {
return false;
}

const navigatorWithHints = window.navigator as NavigatorWithUserAgentData;
const brands = navigatorWithHints.userAgentData?.brands;

if (Array.isArray(brands) && brands.some(brand => re.test(brand.brand))) {
return true;
}

return re.test(window.navigator.userAgent);
}
19 changes: 19 additions & 0 deletions packages/react-simplikit/src/_internal/platform/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Minimal typings for the User-Agent Client Hints API.
* TypeScript's bundled DOM lib does not include these yet in this project.
* @see https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData
*/
export type NavigatorUABrandVersion = {
brand: string;
version: string;
};

export type NavigatorUAData = {
brands: NavigatorUABrandVersion[];
mobile: boolean;
platform: string;
};

export type NavigatorWithUserAgentData = Navigator & {
userAgentData?: NavigatorUAData;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

`isAndroid` es una función de utilidad que detecta si el dispositivo actual usa Android.

Prioriza la API User-Agent Client Hints (`navigator.userAgentData.platform`) y, si no está disponible, busca `"Android"` en las marcas de Client Hints o en `navigator.userAgent`.

Notas:

- Todos los navegadores de Android incluyen el token “Android” en el agente de usuario.
- Chromium en Android suele exponer el `platform` de Client Hints como `"Android"`.

## Interfaz

Expand All @@ -17,15 +20,15 @@ function isAndroid(userAgent?: string): boolean;
<Interface
name="userAgent"
type="string"
description="Cadena opcional del agente de usuario que quieres comprobar. El valor predeterminado es <code>navigator.userAgent</code>."
description="Cadena opcional de agente de usuario a comprobar. Por defecto usa <code>navigator.userAgent</code>."
/>

### Valor de retorno

<Interface
name=""
type="boolean"
description="<code>true</code> si el dispositivo usa Android; <code>false</code> en caso contrario. Devuelve <code>false</code> en entornos de renderizado en el servidor."
description="<code>true</code> si el dispositivo usa Android; <code>false</code> en caso contrario. Devuelve <code>false</code> en entornos de renderizado del lado del servidor."
/>

## Ejemplo
Expand Down
4 changes: 4 additions & 0 deletions packages/react-simplikit/src/utils/isAndroid/isAndroid.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

`isAndroid` is a utility function that detects whether the current device is running Android.

Detection prefers the User-Agent Client Hints API (`navigator.userAgentData.platform`)
and falls back to matching `"Android"` in Client Hints brands or `navigator.userAgent`.

Notes:

- All Android browsers include the token "Android" in the user agent.
- Chromium on Android typically exposes Client Hints `platform` as `"Android"`.

## Interface

Expand Down
Loading
Loading