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
18 changes: 18 additions & 0 deletions .changeset/shared-apple-platform-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@astryxdesign/core': patch
---

[fix] Platform detection reads the client-hints `Unknown` sentinel as no answer, and lives in one place

Follow-up to #5325, which taught `useHotkeys` and `Kbd` to fall through to
`navigator.platform` when `userAgentData.platform` is blank. `Unknown` is the
User-Agent Client Hints spec's own value for "cannot say", and it names a
platform no more than `''` does, yet it still committed to the client-hints
branch and answered "not Apple". It now falls through the same way.

The two detections were independent copies kept aligned by a docstring. They
are now one internal util that both import, so the next change to this logic
cannot land on one surface and miss the other. The util is deliberately not
named in `utils/index.ts`, which would publish it as API.

@Astro-Han
17 changes: 0 additions & 17 deletions packages/core/src/Kbd/Kbd.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ describe('Kbd', () => {
value: originalPlatform,
configurable: true,
});
delete (navigator as {userAgentData?: unknown}).userAgentData;
});

it('renders a single key', () => {
Expand Down Expand Up @@ -55,22 +54,6 @@ describe('Kbd', () => {
expect(screen.getByText('\u2318')).toBeInTheDocument(); // \u2318
});

it('reads a blank userAgentData.platform as unknown, not as non-Mac', () => {
// Builds that rewrite their client-hints identity expose the key with an
// empty value; navigator.platform is the only surface left that answers.
Object.defineProperty(navigator, 'userAgentData', {
value: {platform: ''},
configurable: true,
});
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});

render(<Kbd keys="mod" />);
expect(screen.getByText('\u2318')).toBeInTheDocument();
});

it('maps modifier keys to symbols', () => {
render(<Kbd keys="ctrl+alt+shift+k" />);
expect(screen.getByText('\u2303')).toBeInTheDocument(); // \u2303
Expand Down
28 changes: 2 additions & 26 deletions packages/core/src/Kbd/Kbd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import React, {useSyncExternalStore} from 'react';
import * as stylex from '@stylexjs/stylex';
import {mergeProps} from '../utils';
import {isApplePlatform} from '../utils/isApplePlatform';
import type {BaseProps} from '../BaseProps';
import {themeProps} from '../utils/themeProps';
import {
Expand Down Expand Up @@ -122,31 +123,6 @@ function getServerPlatformSnapshot(): boolean {
return false;
}

/**
* Detects whether the current platform is macOS/iOS.
* Prefers the User-Agent Client Hints API when it names a platform (modern
* Chrome/Edge), falls back to navigator.platform (deprecated but universally
* supported) when it is absent or blank.
*/
function detectMac(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
// Prefer User-Agent Client Hints API (not deprecated)
const uaData = 'userAgentData' in navigator ? navigator.userAgentData : null;
if (uaData && typeof uaData === 'object' && 'platform' in uaData) {
const uaPlatform = (uaData as {platform?: unknown}).platform;
// A blank platform is no answer, not a negative one. Builds that rewrite
// their client-hints identity ship '', so fall through rather than
// reading it as "not Apple".
if (typeof uaPlatform === 'string' && uaPlatform.trim() !== '') {
return /mac/i.test(uaPlatform);
}
}
// Fallback: navigator.platform (deprecated but still shipped everywhere)
return /Mac|iPhone|iPad|iPod/.test(navigator.platform ?? '');
}

export interface KbdProps extends BaseProps<HTMLSpanElement> {
ref?: React.Ref<HTMLSpanElement>;
/**
Expand Down Expand Up @@ -183,7 +159,7 @@ export interface KbdProps extends BaseProps<HTMLSpanElement> {
export function Kbd({keys, ref, xstyle, className, style, ...rest}: KbdProps) {
const isMac = useSyncExternalStore(
subscribeToPlatformChanges,
detectMac,
isApplePlatform,
getServerPlatformSnapshot,
);

Expand Down
18 changes: 0 additions & 18 deletions packages/core/src/hooks/useHotkeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,24 +67,6 @@ describe('useHotkeys', () => {
expect(onPress).toHaveBeenCalledTimes(1);
});

it('reads a blank userAgentData.platform as unknown, not as non-Apple', () => {
// Builds that rewrite their client-hints identity expose the key with an
// empty value; navigator.platform is the only surface left that answers.
vi.stubGlobal('navigator', {
userAgentData: {platform: ''},
platform: 'MacIntel',
});
const onPress = vi.fn();
renderHook(() => useHotkeys([{keys: 'mod+k', onPress}]));

press('k', {ctrlKey: true});
expect(onPress).not.toHaveBeenCalled();

const event = press('k', {metaKey: true});
expect(onPress).toHaveBeenCalledTimes(1);
expect(onPress).toHaveBeenCalledWith(event);
});

it('does not fire a bare key when modifiers are held', () => {
stubApplePlatform();
const onPress = vi.fn();
Expand Down
28 changes: 3 additions & 25 deletions packages/core/src/hooks/useHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
* unless a hotkey opts in via `allowInInputs`.
*
* Platform-aware: `mod` maps to metaKey (⌘) on Apple platforms and
* ctrlKey elsewhere — mirrors the detection used by Kbd.
* ctrlKey elsewhere, through the same isApplePlatform util Kbd reads, so
* displayed and handled shortcuts cannot disagree.
*
* SYNC: When modified, update:
* - /packages/core/src/hooks/index.ts
* - /packages/core/src/hooks/useHotkeys.doc.mjs
*/

import {useEffect, useRef} from 'react';
import {isApplePlatform} from '../utils/isApplePlatform';

/**
* A single keyboard shortcut registration.
Expand Down Expand Up @@ -71,30 +73,6 @@ const KEY_ALIASES: Record<string, string> = {
plus: '+',
};

/**
* Detects whether the current platform is macOS/iOS.
* Prefers the User-Agent Client Hints API when it names a platform (modern
* Chrome/Edge), falls back to navigator.platform (deprecated but universally
* supported) when it is absent or blank.
* Mirrors the detection used by Kbd so displayed and handled shortcuts agree.
*/
function isApplePlatform(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
const uaData = 'userAgentData' in navigator ? navigator.userAgentData : null;
if (uaData && typeof uaData === 'object' && 'platform' in uaData) {
const uaPlatform = (uaData as {platform?: unknown}).platform;
// A blank platform is no answer, not a negative one. Builds that rewrite
// their client-hints identity ship '', so fall through rather than
// reading it as "not Apple".
if (typeof uaPlatform === 'string' && uaPlatform.trim() !== '') {
return /mac/i.test(uaPlatform);
}
}
return /Mac|iPhone|iPad|iPod/.test(navigator.platform ?? '');
}

/**
* Whether the event target is a typing surface where global shortcuts
* should stay silent by default.
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/utils/isApplePlatform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file isApplePlatform.test.ts
* @input Uses vitest, isApplePlatform
* @output Unit tests for isApplePlatform
* @position Testing; validates isApplePlatform.ts implementation
*/

import {describe, it, expect, vi, afterEach} from 'vitest';
import {isApplePlatform} from './isApplePlatform';

afterEach(() => {
vi.unstubAllGlobals();
});

describe('isApplePlatform', () => {
it('trusts a client-hints platform that names one', () => {
vi.stubGlobal('navigator', {
userAgentData: {platform: 'macOS'},
platform: 'Win32',
});
expect(isApplePlatform()).toBe(true);

vi.stubGlobal('navigator', {
userAgentData: {platform: 'Windows'},
platform: 'MacIntel',
});
expect(isApplePlatform()).toBe(false);
});

it.each([
['blank', ''],
['whitespace', ' '],
['the spec Unknown sentinel', 'Unknown'],
['unknown in any case', 'UNKNOWN'],
['a non-string', null],
])('falls through to navigator.platform on %s', (_label, platform) => {
vi.stubGlobal('navigator', {
userAgentData: {platform},
platform: 'MacIntel',
});
expect(isApplePlatform()).toBe(true);

vi.stubGlobal('navigator', {userAgentData: {platform}, platform: 'Win32'});
expect(isApplePlatform()).toBe(false);
});

it('falls back to navigator.platform when client hints are absent', () => {
vi.stubGlobal('navigator', {platform: 'iPhone'});
expect(isApplePlatform()).toBe(true);

vi.stubGlobal('navigator', {platform: 'Linux x86_64'});
expect(isApplePlatform()).toBe(false);
});

it('answers false when there is no navigator at all', () => {
vi.stubGlobal('navigator', undefined);
expect(isApplePlatform()).toBe(false);
});
});
37 changes: 37 additions & 0 deletions packages/core/src/utils/isApplePlatform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file isApplePlatform.ts
* @input Reads navigator.userAgentData.platform and navigator.platform
* @output Exports isApplePlatform
* @position Internal util; the single platform detection behind useHotkeys and Kbd
*
* Deliberately absent from utils/index.ts: packages/core/src/index.ts does
* `export * from './utils'`, so naming it there would publish it as API.
* Import it by path, as interactionModality is imported.
*/

/**
* Detects whether the current platform is macOS/iOS.
* Prefers the User-Agent Client Hints API when it names a platform (modern
* Chrome/Edge), falls back to navigator.platform (deprecated but universally
* supported) when it names none.
*/
export function isApplePlatform(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
const uaData = 'userAgentData' in navigator ? navigator.userAgentData : null;
if (uaData && typeof uaData === 'object' && 'platform' in uaData) {
const uaPlatform = (uaData as {platform?: unknown}).platform;
const named = typeof uaPlatform === 'string' ? uaPlatform.trim() : '';
// A value that names nothing is no answer, not a negative one: builds that
// rewrite their client-hints identity ship '', and 'Unknown' is the spec's
// own sentinel for "cannot say". Both fall through rather than reading as
// "not Apple".
if (named !== '' && named.toLowerCase() !== 'unknown') {
return /mac/i.test(named);
}
}
return /Mac|iPhone|iPad|iPod/.test(navigator.platform ?? '');
}
Loading