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
5 changes: 5 additions & 0 deletions .changeset/moving-tablets-home.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': patch
---

fix: Move tablet detection logic into detectDeviceType for consistent classification across all call sites
53 changes: 53 additions & 0 deletions packages/browser/src/__tests__/utils/user-agent-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,59 @@ describe('user-agent-utils', () => {
expect(detectBrowser(ua, vendor)).toBe('Safari')
})

describe('detectDeviceType with options', () => {
const desktopUA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'

it('should detect Tablet when desktop UA + Android platform + touch + tablet screen', () => {
expect(
detectDeviceType(desktopUA, {
userAgentDataPlatform: 'Android',
maxTouchPoints: 5,
screenWidth: 1280,
screenHeight: 800,
})
).toBe('Tablet')
})

it('should detect Mobile when desktop UA + Android platform + touch + phone screen', () => {
expect(
detectDeviceType(desktopUA, {
userAgentDataPlatform: 'Android',
maxTouchPoints: 5,
screenWidth: 412,
screenHeight: 915,
})
).toBe('Mobile')
})

it('should detect Mobile when desktop UA + Android platform + touch + high DPR phone screen', () => {
// 1200x800 physical at 2x DPR = 600x400 dp, short side 400dp = phone
expect(
detectDeviceType(desktopUA, {
userAgentDataPlatform: 'Android',
maxTouchPoints: 5,
screenWidth: 1200,
screenHeight: 800,
devicePixelRatio: 2,
})
).toBe('Mobile')
})

it('should detect Desktop when desktop UA + no options (backwards compat)', () => {
expect(detectDeviceType(desktopUA)).toBe('Desktop')
})

it('should remain Desktop when desktop UA + Linux platform + touch', () => {
expect(
detectDeviceType(desktopUA, {
userAgentDataPlatform: 'Linux',
maxTouchPoints: 5,
})
).toBe('Desktop')
})
})

test('osVersion', () => {
const osVersions = {
// Windows Phone
Expand Down
11 changes: 9 additions & 2 deletions packages/browser/src/extensions/utils/matcher-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { detectDeviceType } from '@posthog/core'
import { userAgent } from '../../utils/globals'
import { navigator, userAgent, window } from '../../utils/globals'
import { propertyComparisons } from '../../utils/property-utils'
import { PropertyMatchType } from '../../types'

Expand All @@ -10,7 +10,14 @@ export function doesDeviceTypeMatch(deviceTypes?: string[], matchType?: Property
if (!userAgent) {
return false
}
const deviceType = detectDeviceType(userAgent)
const deviceType = detectDeviceType(userAgent, {
// eslint-disable-next-line compat/compat
userAgentDataPlatform: navigator?.userAgentData?.platform,
maxTouchPoints: navigator?.maxTouchPoints,
screenWidth: window?.screen?.width,
screenHeight: window?.screen?.height,
devicePixelRatio: window?.devicePixelRatio,
})
return propertyComparisons[matchType ?? 'icontains'](deviceTypes, [deviceType])
}

Expand Down
22 changes: 8 additions & 14 deletions packages/browser/src/utils/event-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,26 +274,20 @@ export function getEventProperties(
: []
const [os_name, os_version] = detectOS(userAgent)

// Chrome on Android tablets defaults to "request desktop site" mode, sending
// a desktop-like UA (e.g. "X11; Linux x86_64"). The UA-based detectDeviceType()
// falls through to "Desktop". We use the Client Hints API and touch capability
// to catch this case — the browser reports the true platform even when the UA lies.
let deviceType = detectDeviceType(userAgent)
if (deviceType === 'Desktop' && navigator?.userAgentData?.platform === 'Android' && navigator?.maxTouchPoints > 0) {
const screenWidth = window?.screen?.width ?? 0
const screenHeight = window?.screen?.height ?? 0
const shortSide = Math.min(screenWidth, screenHeight)
const shortSideDp = shortSide / (window?.devicePixelRatio ?? 1)
deviceType = shortSideDp >= 600 ? 'Tablet' : 'Mobile'
}

return extend(
stripEmptyProperties({
$os: os_name,
$os_version: os_version,
$browser: detectBrowser(userAgent, navigator.vendor),
$device: detectDevice(userAgent),
$device_type: deviceType,
$device_type: detectDeviceType(userAgent, {
// eslint-disable-next-line compat/compat
userAgentDataPlatform: navigator?.userAgentData?.platform,
maxTouchPoints: navigator?.maxTouchPoints,
screenWidth: window?.screen?.width,
screenHeight: window?.screen?.height,
devicePixelRatio: window?.devicePixelRatio,
}),
$timezone: getTimezone(),
$timezone_offset: getTimezoneOffset(),
}),
Expand Down
24 changes: 21 additions & 3 deletions packages/core/src/utils/user-agent-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@
// Kindle Fire without Silk / Echo Show
/(kf[a-z]{2}wi|aeo[c-r]{2})( bui|\))/i.test(user_agent) ||
// Kindle Fire HD
/(kf[a-z]+)( bui|\)).+silk\//i.test(user_agent)

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings starting with 'kf' and with many repetitions of 'kf'.
This
regular expression
that depends on
library input
may run slow on strings starting with 'kfa)' and with many repetitions of 'kfa)'.
This
regular expression
that depends on library input may run slow on strings starting with 'kf' and with many repetitions of 'kf'.
This
regular expression
that depends on library input may run slow on strings starting with 'kfa)' and with many repetitions of 'kfa)'.
) {
return 'Kindle Fire'
} else if (/(Android|ZTE)/i.test(user_agent)) {
Expand Down Expand Up @@ -336,7 +336,16 @@
}
}

export const detectDeviceType = function (user_agent: string): string {
export const detectDeviceType = function (
user_agent: string,
options?: {
userAgentDataPlatform?: string
maxTouchPoints?: number
screenWidth?: number
screenHeight?: number
devicePixelRatio?: number
}
): string {
const device = detectDevice(user_agent)
if (
device === IPAD ||
Expand All @@ -352,7 +361,16 @@
return 'Wearable'
} else if (device) {
return MOBILE
} else {
return 'Desktop'
}

// Chrome on Android tablets defaults to "request desktop site" mode, sending
// a desktop-like UA (e.g. "X11; Linux x86_64") indistinguishable from desktop Linux.
// The Client Hints API reports the true platform even when the UA lies.
if (options?.userAgentDataPlatform === 'Android' && (options?.maxTouchPoints ?? 0) > 0) {
const shortSide = Math.min(options?.screenWidth ?? 0, options?.screenHeight ?? 0)
const shortSideDp = shortSide / (options?.devicePixelRatio ?? 1)
return shortSideDp >= 600 ? TABLET : MOBILE
}

return 'Desktop'
}
10 changes: 9 additions & 1 deletion packages/react-native/src/native-deps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ const getDeviceType = (): string => {
// Check user agent to determine if it's desktop or mobile
const ua = typeof navigator !== 'undefined' && navigator.userAgent ? navigator.userAgent : ''

deviceType = detectDeviceType(ua)
const nav = typeof navigator !== 'undefined' ? (navigator as any) : undefined

This comment was marked as resolved.

Copy link
Copy Markdown
Member Author

@slshults slshults Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was a reference to the fix in #3154, this was already fixed in a subsequent commit

const win = typeof (globalThis as any).window !== 'undefined' ? (globalThis as any).window : undefined
deviceType = detectDeviceType(ua, {
userAgentDataPlatform: nav?.userAgentData?.platform,
maxTouchPoints: nav?.maxTouchPoints,
screenWidth: win?.screen?.width,
screenHeight: win?.screen?.height,
devicePixelRatio: win?.devicePixelRatio,
})
}
return deviceType
}
Expand Down
Loading