From 31d93e14dbe62d11d5a0cbb6441c240c2dde39a2 Mon Sep 17 00:00:00 2001 From: umsungjun Date: Mon, 10 Aug 2026 16:27:09 +0900 Subject: [PATCH 1/4] Remove em dashes from source, tests, examples, and config The em dash is being retired across this repository, so every occurrence in code comments, test fixtures, example apps, and config is replaced. Each one is rewritten according to the role it played in its sentence rather than by blanket substitution, since the right connector differs per occurrence: a colon for a label, a comma for an aside, a full stop where two statements were joined. The `package.json` description is included because it is published to npm. Three comments are dropped in the same pass, all of which restated their own code: a cross-reference to an unrelated project in the CSR example, a JSDoc line repeating the `listenerCount` signature, and the `addEventListener`/`addListener` narration in the store, which keeps only the Safari < 14 fact that could not be read off the two lines below it. No executable code changes. --- .gitignore | 2 +- e2e/device-detection.spec.ts | 4 ++-- examples/basic/App.tsx | 6 +++--- examples/basic/index.html | 2 +- examples/nextjs/app/DeviceDemo.tsx | 4 ++-- examples/nextjs/app/layout.tsx | 2 +- examples/nextjs/app/page.tsx | 4 ++-- package.json | 2 +- playwright.config.ts | 2 +- src/core/detect.ts | 16 ++++++++-------- src/core/env.ts | 4 ++-- src/core/store.ts | 4 ++-- src/test/fixtures.ts | 8 ++++---- src/test/helpers.ts | 2 +- src/test/hooks.test.tsx | 4 ++-- src/test/matchMediaMock.ts | 1 - src/test/store.test.ts | 2 +- src/useDevice.ts | 2 +- src/useDeviceType.ts | 2 +- src/useOS.ts | 2 +- website/app/(ko)/layout.tsx | 2 +- website/components/InstallTabs.tsx | 2 +- website/components/LiveDemo.tsx | 2 +- website/content/code.ts | 8 ++++---- website/content/types.ts | 2 +- website/lib/fonts.ts | 2 +- website/lib/seo.ts | 8 ++++---- website/next.config.ts | 2 +- 28 files changed, 51 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index a08eea3..9a7fbfd 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,6 @@ playwright-report/ # Next.js (examples) .next/ -# Claude Code — local-only artifacts +# Claude Code local-only artifacts .claude/agent-memory/ .claude/settings.local.json diff --git a/e2e/device-detection.spec.ts b/e2e/device-detection.spec.ts index 0ae5519..f418822 100644 --- a/e2e/device-detection.spec.ts +++ b/e2e/device-detection.spec.ts @@ -77,7 +77,7 @@ test.describe('SSR example (Next.js)', () => { await expect(page.getByTestId('first-type')).toHaveText('desktop'); await expect(page.getByTestId('first-isHydrated')).toHaveText('false'); - // React logs hydration mismatches via console.error — there must be none. + // React logs hydration mismatches via console.error, and there must be none. const hydrationIssues = [...consoleErrors, ...pageErrors].filter((text) => /hydrat|did not match|mismatch/i.test(text) ); @@ -107,7 +107,7 @@ test.describe('reactivity contract', () => { portraitFirst ? 'landscape' : 'portrait' ); - // Device identity is session-static by contract — rotation must not change it. + // Device identity is session-static by contract, so rotation must not change it. await expect(page.getByTestId('type')).toHaveText(expected.type); }); }); diff --git a/examples/basic/App.tsx b/examples/basic/App.tsx index e6ab357..8c57133 100644 --- a/examples/basic/App.tsx +++ b/examples/basic/App.tsx @@ -1,4 +1,4 @@ -// Imports the library source directly (like react-head-safe's example) for a fast edit-refresh loop. +// Imports the library source directly for a fast edit-refresh loop. import { useDevice } from '../../src'; export default function App() { @@ -8,7 +8,7 @@ export default function App() {

react-device-check

- Pure CSR (Vite) — values are correct from the very first render, no + Pure CSR (Vite): values are correct from the very first render, no hydration involved.

@@ -51,7 +51,7 @@ export default function App() {

Tip: toggle the device emulation in your browser devtools and reload, - or rotate a real device — orientation and isTouchPrimary update live. + or rotate a real device, and orientation and isTouchPrimary update live.

); diff --git a/examples/basic/index.html b/examples/basic/index.html index 79a4746..05849d2 100644 --- a/examples/basic/index.html +++ b/examples/basic/index.html @@ -3,7 +3,7 @@ - react-device-check — CSR example + react-device-check CSR example
diff --git a/examples/nextjs/app/DeviceDemo.tsx b/examples/nextjs/app/DeviceDemo.tsx index fd62429..1123033 100644 --- a/examples/nextjs/app/DeviceDemo.tsx +++ b/examples/nextjs/app/DeviceDemo.tsx @@ -11,7 +11,7 @@ export function DeviceDemo() { return ( <>
-

Live values — useDevice()

+

Live values from useDevice()

type
@@ -68,7 +68,7 @@ export function DeviceDemo() {

The server cannot know your device, so it renders the safe default (desktop / unknown). Because the hydration first paint uses the same - default, server and client HTML always match — then the hook corrects + default, server and client HTML always match, and then the hook corrects itself in one post-hydration render. No hydration error is ever logged.

diff --git a/examples/nextjs/app/layout.tsx b/examples/nextjs/app/layout.tsx index 3fa8501..4839508 100644 --- a/examples/nextjs/app/layout.tsx +++ b/examples/nextjs/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from 'next'; import './globals.css'; export const metadata: Metadata = { - title: 'react-device-check — SSR example', + title: 'react-device-check SSR example', description: 'Demonstrates SSR-safe device detection with Next.js App Router.', }; diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 5e876d3..fd3d53e 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -1,12 +1,12 @@ import { DeviceDemo } from './DeviceDemo'; -// A Server Component wrapping the client demo — the library itself carries the 'use client' banner, so importing it here directly would fail with a clear boundary error (by design). +// A Server Component wrapping the client demo. The library itself carries the 'use client' banner, so importing it here directly would fail with a clear boundary error (by design). export default function Page() { return (

react-device-check

- Next.js App Router (SSR) — the server renders a safe default, then the + Next.js App Router (SSR): the server renders a safe default, then the client corrects it right after hydration with zero hydration errors. Open the browser console to verify.

diff --git a/package.json b/package.json index 07456f7..e98782d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-device-check", "version": "0.1.0", - "description": "Lightweight, accurate React hooks for device detection in any React app — CSR or SSR. Detect device type (mobile/tablet/desktop) and OS (iOS/Android/Windows/macOS/Linux) with zero dependencies. SSR-safe in Next.js, handles iPad-as-Mac masquerading, Samsung DeX, and User-Agent reduction. TypeScript support included.", + "description": "Lightweight, accurate React hooks for device detection in any React app, CSR or SSR. Detect device type (mobile/tablet/desktop) and OS (iOS/Android/Windows/macOS/Linux) with zero dependencies. SSR-safe in Next.js, handles iPad-as-Mac masquerading, Samsung DeX, and User-Agent reduction. TypeScript support included.", "author": "umsungjun", "license": "MIT", "repository": { diff --git a/playwright.config.ts b/playwright.config.ts index d5da76b..fa137b8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test'; -// Real-browser E2E across a device matrix: each project emulates a device profile (UA + touch + viewport) against both example apps — CSR (Vite, :3001) and SSR (Next.js, :3002). +// Real-browser E2E across a device matrix: each project emulates a device profile (UA + touch + viewport) against both example apps: CSR (Vite, :3001) and SSR (Next.js, :3002). export default defineConfig({ testDir: './e2e', fullyParallel: true, diff --git a/src/core/detect.ts b/src/core/detect.ts index c2be0bf..0c83ecb 100644 --- a/src/core/detect.ts +++ b/src/core/detect.ts @@ -14,7 +14,7 @@ const UA_DATA_PLATFORM_MAP: Record = { // Phone-class shortest screen side in CSS px: the largest iPhone is ~440, the smallest iPad is 744. const PHONE_SCREEN_MAX = 600; -// TV form factors (Android TV, Fire TV 'AFT*' models, Chromecast 'CrKey', Sony BRAVIA, Roku). Android TV UAs carry 'Android' without 'Mobile', which would otherwise land in the tablet bucket — the wrongest fit for a 10-foot no-touch UI; with a three-way taxonomy, desktop is the best fit. \bTV\b also covers 'SMART-TV' and 'Android TV'; SmartTV/GoogleTV lack the word boundary and are listed explicitly. +// TV form factors (Android TV, Fire TV 'AFT*' models, Chromecast 'CrKey', Sony BRAVIA, Roku). Android TV UAs carry 'Android' without 'Mobile', which would otherwise land in the tablet bucket, the wrongest fit for a 10-foot no-touch UI; with a three-way taxonomy, desktop is the best fit. \bTV\b also covers 'SMART-TV' and 'Android TV'; SmartTV/GoogleTV lack the word boundary and are listed explicitly. const TV_UA = /\bTV\b|SmartTV|GoogleTV|CrKey|Roku|\bAFT[A-Z0-9]|BRAVIA/; // All regexes are case-sensitive on purpose: real UA tokens are cased exactly like this, and case-sensitivity keeps lowercase tokens such as jsdom's '(darwin)' out of the Mac/Linux buckets. @@ -29,9 +29,9 @@ function sniffOS(ua: string): OS { } /** - * Pure device detection engine. Same input always produces the same output — no globals, no media queries. + * Pure device detection engine. Same input always produces the same output, with no globals and no media queries. * - * Signal priority: Client Hints (`uaData`, authoritative when present — only Chromium exposes it) → UA string cross-checked with `maxTouchPoints` (Safari/Firefox/WebViews) → fallback. + * Signal priority: Client Hints (`uaData`, authoritative when present; only Chromium exposes it) → UA string cross-checked with `maxTouchPoints` (Safari/Firefox/WebViews) → fallback. * * Works anywhere: pass `navigator`-derived values on the client or a request's `user-agent` header on the server. */ @@ -44,21 +44,21 @@ export function detectDevice( os: options?.fallback?.os ?? ('unknown' as OS), }; - // STEP 0 — no usable signals (SSR, bare React Native). + // STEP 0: no usable signals (SSR, bare React Native). if (!input || (!input.ua && !input.uaData)) return fallback; const ua = input.ua ?? ''; const maxTouchPoints = input.maxTouchPoints ?? 0; const uaData = input.uaData; - // TIER 1 — Chromium Client Hints. Safe to trust first: no iOS browser ever exposes userAgentData (they are all WebKit), so the iPad-as-Mac unmasking below is never bypassed. + // TIER 1: Chromium Client Hints. Safe to trust first: no iOS browser ever exposes userAgentData (they are all WebKit), so the iPad-as-Mac unmasking below is never bypassed. if (uaData && typeof uaData.mobile === 'boolean') { let os = UA_DATA_PLATFORM_MAP[uaData.platform ?? ''] ?? sniffOS(ua); - // Samsung DeX / desktop-mode requests report a Linux platform while the UA keeps the SamsungBrowser token — effectively always an Android device in desktop clothing. + // Samsung DeX / desktop-mode requests report a Linux platform while the UA keeps the SamsungBrowser token, so it is effectively always an Android device in desktop clothing. if (os === 'linux' && /SamsungBrowser/.test(ua)) os = 'android'; if (uaData.mobile) return { type: 'mobile', os }; if (os === 'android') { - // mobile === false on Android: an Android UA without the 'Mobile' token is a tablet (Google's official rule) — unless it is a TV. A UA that dropped the Android token entirely is a desktop-form request (Samsung DeX, "Request desktop site"). + // mobile === false on Android: an Android UA without the 'Mobile' token is a tablet (Google's official rule), unless it is a TV. A UA that dropped the Android token entirely is a desktop-form request (Samsung DeX, "Request desktop site"). return { type: /Android/.test(ua) && !TV_UA.test(ua) ? 'tablet' : 'desktop', os, @@ -68,7 +68,7 @@ export function detectDevice( return { type: 'desktop', os }; } - // TIER 2 — UA string + touch cross-checks (Safari, Firefox, WebViews, legacy Chromium). + // TIER 2: UA string + touch cross-checks (Safari, Firefox, WebViews, legacy Chromium). // iPhone/iPod before any Mac check: their UAs contain 'like Mac OS X'. if (/iPhone|iPod/.test(ua)) return { type: 'mobile', os: 'ios' }; diff --git a/src/core/env.ts b/src/core/env.ts index 0b49e1c..8c0cdc7 100644 --- a/src/core/env.ts +++ b/src/core/env.ts @@ -5,8 +5,8 @@ export const isServer = typeof window === 'undefined' || typeof navigator === 'undefined'; /** - * Reads detection signals from globals. Returns `undefined` outside a browser environment. Never accessed at module top level — all detection is lazy so importing the library is always SSR-safe. - * Gated on `window` (not just `navigator`) because Node 21+ ships a global `navigator` whose `platform` reflects the server machine — trusting it would misreport the server's OS as the device. Non-window environments (workers, servers) should pass explicit input to `detectDevice` instead. + * Reads detection signals from globals. Returns `undefined` outside a browser environment. Never accessed at module top level, because all detection is lazy so importing the library is always SSR-safe. + * Gated on `window` (not just `navigator`) because Node 21+ ships a global `navigator` whose `platform` reflects the server machine, and trusting it would misreport the server's OS as the device. Non-window environments (workers, servers) should pass explicit input to `detectDevice` instead. */ export function getNavigatorInput(): DetectionInput | undefined { if (isServer) return undefined; diff --git a/src/core/store.ts b/src/core/store.ts index 84adf7a..ea403dd 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -3,7 +3,7 @@ import { isServer } from './env'; import { getStaticInfo, SERVER_STATIC } from './static'; /** - * Snapshot for the server render and the hydration first paint. Frozen module constant — getServerSnapshot runs on every SSR/hydration render and a fresh object would make React loop. + * Snapshot for the server render and the hydration first paint. Frozen module constant, because getServerSnapshot runs on every SSR/hydration render and a fresh object would make React loop. */ export const SERVER_SNAPSHOT: DeviceInfo = Object.freeze({ ...SERVER_STATIC, @@ -53,7 +53,7 @@ function onChange(): void { } } -// addEventListener with addListener fallback (Safari < 14). +// addListener is the Safari < 14 path. function listen(mql: MediaQueryList, cb: () => void): void { if (mql.addEventListener) mql.addEventListener('change', cb); else mql.addListener(cb); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 6c38771..92e5240 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -141,7 +141,7 @@ export const FIXTURES: Fixture[] = [ expected: { type: 'mobile', os: 'android' }, }, - // ── B. iOS / WebKit (Tier 2 — no uaData ever exists on iOS) ───────── + // ── B. iOS / WebKit (Tier 2: no uaData ever exists on iOS) ───────── { name: 'iPhone Safari (iOS 26 frozen UA)', ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1', @@ -172,7 +172,7 @@ export const FIXTURES: Fixture[] = [ expected: { type: 'tablet', os: 'ios' }, }, { - name: 'iPad desktop mode (iPadOS 13+ default — Macintosh UA unmasked)', + name: 'iPad desktop mode (iPadOS 13+ default, Macintosh UA unmasked)', ua: SAFARI_DESKTOP_UA, maxTouchPoints: 5, platform: 'MacIntel', @@ -210,7 +210,7 @@ export const FIXTURES: Fixture[] = [ expected: { type: 'desktop', os: 'macos' }, }, { - name: 'Chrome iOS (CriOS — WebKit shell, no uaData)', + name: 'Chrome iOS (CriOS, WebKit shell, no uaData)', ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/146.0.0.0 Mobile/15E148 Safari/604.1', maxTouchPoints: 5, platform: 'iPhone', @@ -231,7 +231,7 @@ export const FIXTURES: Fixture[] = [ expected: { type: 'mobile', os: 'ios' }, }, - // ── C. Firefox (Tier 2 — no uaData) ───────────────────────────────── + // ── C. Firefox (Tier 2: no uaData) ───────────────────────────────── { name: 'Firefox Android phone (Mobile token)', ua: 'Mozilla/5.0 (Android 15; Mobile; rv:136.0) Gecko/136.0 Firefox/136.0', diff --git a/src/test/helpers.ts b/src/test/helpers.ts index ddd2ed2..27d9a0c 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest'; import type { Fixture } from './fixtures'; /** - * Replaces the global navigator (and screen, when provided) with fixture values. vi.stubGlobal swaps the whole global so vi.unstubAllGlobals() restores everything in one call — cleaner than per-property defineProperty juggling against jsdom's prototype getters. + * Replaces the global navigator (and screen, when provided) with fixture values. vi.stubGlobal swaps the whole global so vi.unstubAllGlobals() restores everything in one call, which is cleaner than per-property defineProperty juggling against jsdom's prototype getters. */ export function stubNavigatorFromFixture(fx: Partial): void { vi.stubGlobal('navigator', { diff --git a/src/test/hooks.test.tsx b/src/test/hooks.test.tsx index 7d0ae57..9883c68 100644 --- a/src/test/hooks.test.tsx +++ b/src/test/hooks.test.tsx @@ -180,7 +180,7 @@ describe('hooks', () => { }); describe('hydration', () => { - // react-dom/client does not exist on React 17 — the fallback path there renders the client snapshot directly and is covered by the compat CI leg. + // react-dom/client does not exist on React 17, so the fallback path there renders the client snapshot directly and is covered by the compat CI leg. const hasModernReact = parseInt(React.version, 10) >= 18; it.skipIf(!hasModernReact)( @@ -188,7 +188,7 @@ describe('hooks', () => { async () => { stubNavigatorFromFixture(IPHONE); const { renderToString } = await import('react-dom/server'); - // Vite's import analysis resolves literal dynamic imports at transform time (even with @vite-ignore), which crashes suite loading on React 17 where react-dom/client does not exist. A variable specifier is opaque to the analyzer, deferring resolution to runtime — after skipIf has excluded this test on React 17. + // Vite's import analysis resolves literal dynamic imports at transform time (even with @vite-ignore), which crashes suite loading on React 17 where react-dom/client does not exist. A variable specifier is opaque to the analyzer, deferring resolution to runtime, after skipIf has excluded this test on React 17. const clientSpecifier = 'react-dom/client'; const { hydrateRoot } = (await import( /* @vite-ignore */ clientSpecifier diff --git a/src/test/matchMediaMock.ts b/src/test/matchMediaMock.ts index 7cdf7cd..56e6d68 100644 --- a/src/test/matchMediaMock.ts +++ b/src/test/matchMediaMock.ts @@ -5,7 +5,6 @@ type MediaListener = (e: { matches: boolean; media: string }) => void; export interface MatchMediaController { /** Updates the state of a query and fires its change listeners. */ set(query: string, matches: boolean): void; - /** Number of active change listeners for a query (for lifecycle assertions). */ listenerCount(query: string): number; } diff --git a/src/test/store.test.ts b/src/test/store.test.ts index fb76efe..5978c9c 100644 --- a/src/test/store.test.ts +++ b/src/test/store.test.ts @@ -92,7 +92,7 @@ describe('device store', () => { // The media state flips before any subscriber exists (render → passive effect gap). media.set(PORTRAIT_QUERY, false); - expect(getSnapshot()).toBe(stale); // no listener yet — still stale by design + expect(getSnapshot()).toBe(stale); // no listener yet, still stale by design const unsubscribe = subscribe(() => {}); expect(getSnapshot().orientation).toBe('landscape'); diff --git a/src/useDevice.ts b/src/useDevice.ts index a087d1d..33c61ef 100644 --- a/src/useDevice.ts +++ b/src/useDevice.ts @@ -5,7 +5,7 @@ import { getServerSnapshot, getSnapshot, subscribe } from './core/store'; /** * Returns the full device snapshot. * - * `type`/`os` and the derived booleans are static for the session (user agent facts cannot change without a page load). `isTouchPrimary` and `orientation` are reactive — they update live via matchMedia change listeners. + * `type`/`os` and the derived booleans are static for the session (user agent facts cannot change without a page load). `isTouchPrimary` and `orientation` are reactive: they update live via matchMedia change listeners. * * SSR contract: the server render and the hydration first paint both return the frozen default (`desktop`/`unknown`, `isHydrated: false`), so server and client HTML always match. Immediately after hydration the hook re-renders once with the real values. */ diff --git a/src/useDeviceType.ts b/src/useDeviceType.ts index 70846d6..c231c21 100644 --- a/src/useDeviceType.ts +++ b/src/useDeviceType.ts @@ -7,7 +7,7 @@ const getType = (): DeviceType => getStaticInfo().type; const getServerType = (): DeviceType => SERVER_STATIC.type; /** - * Returns the device type only. Static — no media listeners are ever attached, and importing only this hook tree-shakes the whole reactive store away. + * Returns the device type only. Static: no media listeners are ever attached, and importing only this hook tree-shakes the whole reactive store away. */ export function useDeviceType(): DeviceType { return useSES(emptySubscribe, getType, getServerType); diff --git a/src/useOS.ts b/src/useOS.ts index 3ea043b..64938fd 100644 --- a/src/useOS.ts +++ b/src/useOS.ts @@ -7,7 +7,7 @@ const getOS = (): OS => getStaticInfo().os; const getServerOS = (): OS => SERVER_STATIC.os; /** - * Returns the operating system family only. Static — no media listeners are ever attached. Returns `'unknown'` on the server and during the hydration first paint. + * Returns the operating system family only. Static: no media listeners are ever attached. Returns `'unknown'` on the server and during the hydration first paint. */ export function useOS(): OS { return useSES(emptySubscribe, getOS, getServerOS); diff --git a/website/app/(ko)/layout.tsx b/website/app/(ko)/layout.tsx index 891f887..67d4af0 100644 --- a/website/app/(ko)/layout.tsx +++ b/website/app/(ko)/layout.tsx @@ -7,7 +7,7 @@ import '../globals.css'; // Fallback for routes without their own metadata; pages override via buildMetadata export const metadata: Metadata = { metadataBase: new URL(SITE_URL) }; -// Second root layout for the /ko subtree — navigation across locales is a full page load by design +// Second root layout for the /ko subtree. Navigation across locales is a full page load by design export default function KoLayout({ children }: { children: ReactNode }) { return ( diff --git a/website/components/InstallTabs.tsx b/website/components/InstallTabs.tsx index e1368fd..73f3daf 100644 --- a/website/components/InstallTabs.tsx +++ b/website/components/InstallTabs.tsx @@ -23,7 +23,7 @@ export default function InstallTabs({ strings }: InstallTabsProps) { await navigator.clipboard.writeText(INSTALL_COMMANDS[active].command); setCopied(true); } catch { - // Copy silently failed — keep the hint label so the UI never claims success + // Copy silently failed, so keep the hint label so the UI never claims success } }; diff --git a/website/components/LiveDemo.tsx b/website/components/LiveDemo.tsx index 84541ae..a1bbc81 100644 --- a/website/components/LiveDemo.tsx +++ b/website/components/LiveDemo.tsx @@ -12,7 +12,7 @@ type Row = readonly [label: string, value: string]; export default function LiveDemo({ strings }: LiveDemoProps) { const device = useDevice(); - // Freeze the hydration-render snapshot — this is exactly what the server sent + // Freeze the hydration-render snapshot: this is exactly what the server sent const [firstPaint] = useState(device); const toRows = (d: typeof device): Row[] => [ diff --git a/website/content/code.ts b/website/content/code.ts index 2c85365..5260b21 100644 --- a/website/content/code.ts +++ b/website/content/code.ts @@ -1,4 +1,4 @@ -// Locale-independent code snippets — code and comments stay English for both locales +// Locale-independent code snippets. Code and comments stay English for both locales // One entry per package manager, rendered as click-to-copy tabs in the hero export const INSTALL_COMMANDS = [ @@ -17,7 +17,7 @@ export default function Page() { const { type, os, isMobile, isHydrated } = useDevice(); // Server render & hydration first paint: type = 'desktop', isHydrated = false. - // One render later the real device shows up — no hydration mismatch, ever. + // One render later the real device shows up. No mismatch, ever. if (!isHydrated) return ; if (isMobile && os === 'ios') return ; @@ -29,7 +29,7 @@ export default function Page() { code: `import { useIsMobile, useOS } from 'react-device-check'; export default function DownloadButton() { - const isMobile = useIsMobile(); // boolean only — ~1.1 kB total + const isMobile = useIsMobile(); // boolean only, ~1.1 kB total const os = useOS(); // 'ios' | 'android' | ... if (isMobile && os === 'ios') return ; @@ -42,7 +42,7 @@ export default function DownloadButton() { code: `import { detectDevice } from 'react-device-check'; export function middleware(request: Request) { - // No React, no globals — inject any signals you have + // No React, no globals. Inject the signals you have const { type } = detectDevice({ ua: request.headers.get('user-agent') ?? '', }); diff --git a/website/content/types.ts b/website/content/types.ts index ebd03d1..3bf4dfd 100644 --- a/website/content/types.ts +++ b/website/content/types.ts @@ -23,7 +23,7 @@ export interface DemoStrings extends SectionHeading { hint: string; } -// One interface for every visible string — TypeScript keeps en/ko structurally in sync +// One interface for every visible string. TypeScript keeps en/ko structurally in sync export interface LandingStrings { header: { langLabel: string; diff --git a/website/lib/fonts.ts b/website/lib/fonts.ts index f27ba5a..a410035 100644 --- a/website/lib/fonts.ts +++ b/website/lib/fonts.ts @@ -1,6 +1,6 @@ import { Inter, JetBrains_Mono } from 'next/font/google'; -// Self-hosted via next/font at build time — no external requests at runtime, zero CLS +// Self-hosted via next/font at build time, so no external requests at runtime and zero CLS export const inter = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap' }); export const jetbrainsMono = JetBrains_Mono({ diff --git a/website/lib/seo.ts b/website/lib/seo.ts index 5fa1845..a7a0b49 100644 --- a/website/lib/seo.ts +++ b/website/lib/seo.ts @@ -1,14 +1,14 @@ import type { Metadata } from 'next'; import type { Locale } from '@/content/types'; -// Single source of truth for the deployed origin — change here if a custom domain is added +// Single source of truth for the deployed origin. Change here if a custom domain is added export const SITE_URL = 'https://react-device-check-site.vercel.app'; export const GITHUB_URL = 'https://github.com/umsungjun/react-device-check'; export const NPM_URL = 'https://www.npmjs.com/package/react-device-check'; const TITLES: Record = { - en: 'react-device-check — React device detection hooks for CSR & SSR', - ko: 'react-device-check — CSR·SSR 모두를 위한 React 기기 판별 훅', + en: 'react-device-check: React device detection hooks for CSR & SSR', + ko: 'react-device-check: CSR·SSR 모두를 위한 React 기기 판별 훅', }; const DESCRIPTIONS: Record = { @@ -49,7 +49,7 @@ export const buildMetadata = (locale: Locale): Metadata => ({ description: DESCRIPTIONS[locale], locale: locale === 'en' ? 'en_US' : 'ko_KR', alternateLocale: locale === 'en' ? 'ko_KR' : 'en_US', - // Static file in public/ — explicit reference because the opengraph-image + // Static file in public/, referenced explicitly because the opengraph-image // file convention doesn't inject meta tags across route-group root layouts images: [{ url: '/og.png', width: 1200, height: 630, alt: TITLES[locale] }], }, diff --git a/website/next.config.ts b/website/next.config.ts index bd048f5..1efea54 100644 --- a/website/next.config.ts +++ b/website/next.config.ts @@ -4,7 +4,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // Linting is owned by the repo root; the website has no eslint install of its own eslint: { ignoreDuringBuilds: true }, - // The repo root has its own lockfile — pin tracing here so Next doesn't infer the wrong root + // The repo root has its own lockfile, so pin tracing here so Next doesn't infer the wrong root outputFileTracingRoot: path.join(__dirname), }; From cc4074fbc9f4f55a8232e5ce5dd303a6a1186901 Mon Sep 17 00:00:00 2001 From: umsungjun Date: Mon, 10 Aug 2026 16:27:51 +0900 Subject: [PATCH 2/4] Document the no-em-dash convention The rule now lives next to the other project conventions so it survives beyond the cleanup that introduced it. Comma, colon, parentheses, or a separate sentence take its place. The en dash in a numeric range such as `React 17-19` stays, since that is correct typography and not what the rule targets. The grep that verifies it is written down too, so a regression is one command away from being caught. --- CLAUDE.md | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e6d5fc3..5af5d7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,20 +29,20 @@ pnpm vitest run -t "test name pattern" ### Core flow -1. `src/types.ts` — all public types (`DeviceType`, `OS`, `DeviceInfo`, `DetectionInput`, …) -2. `src/core/detect.ts` — `detectDevice(input, options)`: the pure decision-tree engine. Tier 1 trusts Chromium Client Hints (`uaData.mobile`/`platform`); Tier 2 parses the UA string cross-checked with `maxTouchPoints` (iPad-as-Mac unmasking). Deterministic: same input → same output; no globals. -3. `src/core/env.ts` — `isServer` + `getNavigatorInput()`: the only place globals are read. Gated on `window` because Node 21+ ships a global `navigator` that would misreport the server's OS. -4. `src/core/static.ts` — session cache of the static info + the frozen `SERVER_STATIC` default (`desktop`/`unknown`). -5. `src/core/store.ts` — the reactive store for `useDevice()`: lazily attaches two `matchMedia` listeners (`(pointer: coarse)`, `(orientation: portrait)`) with the first subscriber, caches the snapshot object so its reference only changes when a reactive field changes (useSyncExternalStore requirement). -6. `src/compat.ts` — `useSES`: native `useSyncExternalStore` when available, otherwise a ~20-line React 17 fallback. Uses namespace property access (not a named import) so React 17 doesn't throw. -7. `src/useDevice.ts` / `src/useDeviceType.ts` / `src/useOS.ts` — thin hook wrappers. Static hooks import only `core/static`, so importing them alone tree-shakes the reactive store away (verified by the size-limit budgets). +1. `src/types.ts`: all public types (`DeviceType`, `OS`, `DeviceInfo`, `DetectionInput`, …) +2. `src/core/detect.ts`: `detectDevice(input, options)`: the pure decision-tree engine. Tier 1 trusts Chromium Client Hints (`uaData.mobile`/`platform`); Tier 2 parses the UA string cross-checked with `maxTouchPoints` (iPad-as-Mac unmasking). Deterministic: same input → same output; no globals. +3. `src/core/env.ts`: `isServer` + `getNavigatorInput()`: the only place globals are read. Gated on `window` because Node 21+ ships a global `navigator` that would misreport the server's OS. +4. `src/core/static.ts`: session cache of the static info + the frozen `SERVER_STATIC` default (`desktop`/`unknown`). +5. `src/core/store.ts`: the reactive store for `useDevice()`: lazily attaches two `matchMedia` listeners (`(pointer: coarse)`, `(orientation: portrait)`) with the first subscriber, caches the snapshot object so its reference only changes when a reactive field changes (useSyncExternalStore requirement). +6. `src/compat.ts`: `useSES`: native `useSyncExternalStore` when available, otherwise a ~20-line React 17 fallback. Uses namespace property access (not a named import) so React 17 doesn't throw. +7. `src/useDevice.ts` / `src/useDeviceType.ts` / `src/useOS.ts`: thin hook wrappers. Static hooks import only `core/static`, so importing them alone tree-shakes the reactive store away (verified by the size-limit budgets). ### Invariants to preserve -- **No module-top-level access to `window`/`navigator`** — all detection is lazy. This is the SSR-safety foundation. -- **Snapshot references must be stable** — `getServerSnapshot` returns a frozen module constant; the client snapshot is cached and only replaced when a reactive field changes. Fresh objects per call make React loop infinitely. +- **No module-top-level access to `window`/`navigator`**: all detection is lazy. This is the SSR-safety foundation. +- **Snapshot references must be stable**: `getServerSnapshot` returns a frozen module constant; the client snapshot is cached and only replaced when a reactive field changes. Fresh objects per call make React loop infinitely. - **Branch order in `detect.ts` matters**: iPhone before Mac (`like Mac OS X`), Android before Windows/Linux (`Linux; Android`), the generic `/Mobi/` catch-all before Windows/Linux (Windows Phone/Tizen/Sailfish carry desktop OS tokens plus a mobile marker), TV markers before the Android tablet verdict, Tier 1 before Tier 2 (safe because iOS browsers never expose `userAgentData`). Case-sensitive regexes keep jsdom's lowercase `(darwin)` out. -- **`maxTouchPoints` is consulted ONLY in the Apple-masquerade branch** — touch laptops/Surface must stay `desktop`. +- **`maxTouchPoints` is consulted ONLY in the Apple-masquerade branch**: touch laptops/Surface must stay `desktop`. - **`type`/`os` are static per session by contract**; only `isTouchPrimary`/`orientation` are reactive. ### SSR contract @@ -51,11 +51,11 @@ Server render and hydration first paint both return the frozen default (`desktop ### Testing -- `src/test/fixtures.ts` — 48 real-world UA fixtures; `detect.test.ts` runs the matrix via pure injection (no global mocks). Update the fixture counts in both READMEs and this file when adding fixtures. +- `src/test/fixtures.ts`: 48 real-world UA fixtures; `detect.test.ts` runs the matrix via pure injection (no global mocks). Update the fixture counts in both READMEs and this file when adding fixtures. - `src/test/helpers.ts` (`vi.stubGlobal` navigator stub) + `matchMediaMock.ts` (controllable harness) for store/hook tests; `setup.ts` resets the session caches and unstubs globals after each test. -- Hook tests use **probe components, not renderHook** — the React 17 CI leg pins RTL 12 which has no renderHook. +- Hook tests use **probe components, not renderHook**: the React 17 CI leg pins RTL 12 which has no renderHook. - `ssr.test.tsx` runs with `// @vitest-environment node` to exercise the real no-DOM path. -- `e2e/device-detection.spec.ts` — Playwright matrix (iPhone 15, iPad Pro 11, Galaxy S24, Galaxy Tab S9 with `isMobile: false` to reproduce real tablet Client Hints, desktop Chrome/Safari) against both examples. The SSR test asserts the raw server HTML and zero hydration console errors. +- `e2e/device-detection.spec.ts`: Playwright matrix (iPhone 15, iPad Pro 11, Galaxy S24, Galaxy Tab S9 with `isMobile: false` to reproduce real tablet Client Hints, desktop Chrome/Safari) against both examples. The SSR test asserts the raw server HTML and zero hydration console errors. ### Adding a detection rule @@ -65,11 +65,16 @@ Server render and hydration first paint both return the frozen default (`desktop ### Code style -Write all code comments in English — this overrides the global "Korean comments" rule. The library is published to npm for an international audience. User-facing documentation keeps a Korean translation (`README.ko.md`). +Write all code comments in English. This overrides the global "Korean comments" rule. The library is published to npm for an international audience. User-facing documentation keeps a Korean translation (`README.ko.md`). + +Never use an em dash (`—`) anywhere: code, comments, commit messages, READMEs, website copy, or the npm `description`. +Use a comma, a colon, parentheses, or a separate sentence instead. +`grep -rn '—'` outside `node_modules`/`.next`/`dist`/`coverage` must stay empty. +The en dash in a numeric range (`React 17–19`) is not affected. ### Commit messages -Write commit messages in English — subject and body — overriding the global Korean commit-message convention. The repository is public and its history is read by an international audience, same rationale as the code-comment rule above. Keep the rest of the global convention: one sentence per line (no width-driven wrapping), and no `Co-Authored-By` footer. +Write commit messages in English, subject and body, overriding the global Korean commit-message convention. The repository is public and its history is read by an international audience, same rationale as the code-comment rule above. Keep the rest of the global convention: one sentence per line (no width-driven wrapping), and no `Co-Authored-By` footer. ### Build output @@ -79,12 +84,12 @@ React is the only external (peer dependency). Bundle budgets: everything ≤ 2 k ### Examples -- `examples/basic` — Vite CSR app importing the library source (`../../src`) directly. -- `examples/nextjs` — Next.js 15 App Router app consuming the **built package** via `"react-device-check": "link:../.."` — run `pnpm build` at the root before starting it. +- `examples/basic`: Vite CSR app importing the library source (`../../src`) directly. +- `examples/nextjs`: Next.js 15 App Router app consuming the **built package** via `"react-device-check": "link:../.."`, so run `pnpm build` at the root before starting it. ### Website -`website/` is a standalone Next.js 15 promo/landing site (own lockfile, not a workspace member) consuming the **published npm package** — unlike both examples, it needs no root build. English at `/`, Korean at `/ko` via two route-group root layouts (each sets its own ``); hreflang/canonical/OG metadata come from `website/lib/seo.ts` (`SITE_URL` is the single deploy-URL definition). The OG image is the static `website/public/og.png`, referenced explicitly in `lib/seo.ts` (the `opengraph-image` file convention does not inject meta tags across route-group root layouts). Deployed on Vercel with Root Directory = `website`; excluded from CI, lint, size-limit, and the Playwright E2E matrix. The examples' ports and `data-testid` contracts are untouched by it. +`website/` is a standalone Next.js 15 promo/landing site (own lockfile, not a workspace member) consuming the **published npm package**. Unlike both examples, it needs no root build. English at `/`, Korean at `/ko` via two route-group root layouts (each sets its own ``); hreflang/canonical/OG metadata come from `website/lib/seo.ts` (`SITE_URL` is the single deploy-URL definition). The OG image is the static `website/public/og.png`, referenced explicitly in `lib/seo.ts` (the `opengraph-image` file convention does not inject meta tags across route-group root layouts). Deployed on Vercel with Root Directory = `website`; excluded from CI, lint, size-limit, and the Playwright E2E matrix. The examples' ports and `data-testid` contracts are untouched by it. ### Package manager From 59375abf5b5f554481f508238643474d9bc1e993 Mon Sep 17 00:00:00 2001 From: umsungjun Date: Mon, 10 Aug 2026 16:28:08 +0900 Subject: [PATCH 3/4] Rewrite both READMEs around the problem, not the implementation The first section used to be "Why react-device-check?", which opened on `MacIntel`, the `Mobile` token rule, UA freezing, `sideEffects: false`, and size-limit before the reader had seen a single line of code. It now opens on what the hook returns, then the three problems that make those values hard to get right, each followed by how they are solved. Nothing is lost: the accuracy claims moved into "how detection works", the SSR claims into the SSR section, and the bundle-size claim next to the import example that demonstrates it. Sections that repeated each other are gone. The "Features" list restated the API reference and the badges, and its two unique facts moved up into the lede. The test-matrix bullet duplicated the Testing section almost verbatim. The comparison with react-device-detect moved above the fold, where it answers why the obvious alternative does not solve the three problems just described. It also sat directly above "known limitations", which made that heading read as a list of the competitor's limitations. "How detection works" named its rules without stating them, so `Mobile` token rule, `Mobi` rule, and shortest screen edge are now spelled out with the numbers behind them. The limitations are grouped by why they exist, separating deliberate decisions from cases nothing can resolve, and the Korean heading drops a literal translation of "known limitations". The roadmap is removed: it is hard to mark as non-binding and turns stale when work slips. The unit test count was stale in both files, claiming 76 in one section and 85 in another. The suite reports 85. --- README.ko.md | 184 +++++++++++++++++++++++++++-------------------- README.md | 198 +++++++++++++++++++++++++++++---------------------- 2 files changed, 217 insertions(+), 165 deletions(-) diff --git a/README.ko.md b/README.ko.md index c2e926c..efe5cbf 100644 --- a/README.ko.md +++ b/README.ko.md @@ -8,46 +8,52 @@ **웹사이트 / 라이브 데모**: [react-device-check-site.vercel.app/ko](https://react-device-check-site.vercel.app/ko) -**경량 · 정확한 React 기기 판별 훅 — CSR SPA부터 Next.js SSR까지 어떤 React 앱에서든.** 사용자가 폰인지 태블릿인지 데스크톱인지, 어떤 OS인지를 의존성 0개, 전체 ~1.5 kB(min+brotli)로 판별합니다. Next.js에서 hydration 에러가 발생하지 않습니다. +**사용자가 폰인지 태블릿인지 데스크톱인지, 어떤 OS를 쓰는지 알려주는 React 훅입니다.** 의존성이 없고 전부 가져다 써도 ~1.5 kB(min+brotli)입니다. React 17, 18, 19에서 동작하고 타입 정의를 함께 배포하며, Next.js처럼 서버에서 HTML을 미리 만드는 환경에서도 에러가 나지 않습니다. -2026년의 기기 판별은 보기보다 어렵습니다. iPad는 자신을 Mac이라고 위장하고, Chrome은 User-Agent 문자열을 동결했으며(모든 안드로이드 모델명이 `K`로 보고됨), Samsung DeX는 폰에서 데스크톱 리눅스 UA를 보내고, iOS 26은 OS 버전 토큰을 영구 동결했습니다. `react-device-check`는 여전히 동작하는 신호들 — User-Agent Client Hints, UA 문자열, `maxTouchPoints` 교차검증 — 을 정규식 데이터베이스가 아닌 작고 결정론적인 판별 트리로 융합합니다. +## 이런 문제를 풀어줍니다 -## 왜 react-device-check인가? +훅 하나를 부르면 기기 종류와 OS가 나옵니다. -- **다른 라이브러리가 틀리는 곳에서 정확** — iPadOS 13+가 macOS 데스크톱 UA를 보내도 iPad를 태블릿으로 정확히 판별(`MacIntel` + 멀티터치 언마스킹). 안드로이드 태블릿은 공식 `Mobile` 토큰 규칙으로 구분. Samsung DeX, 웹뷰(카카오톡, 인스타그램 등), 레거시 UA까지 처리. -- **Client Hints 우선** — Chromium에서는 `navigator.userAgentData`를 신뢰(UA 동결에 면역), 그 외에는 UA 파싱으로 폴백. 이 둘을 모두 하는 라이브러리는 사실상 없습니다. -- **구조적으로 SSR-safe** — 서버 렌더와 hydration 첫 페인트가 항상 일치하므로 React 18/19에서는 hydration mismatch가 기록되지 않습니다 (React 17 + SSR은 문서화된 예외 — 알려진 한계 참조). hydration 직후 1회 렌더로 실제 값으로 교정됩니다. -- **작고 tree-shakeable** — 런타임 의존성 0개, `sideEffects: false`, ESM/CJS 듀얼. `useIsMobile`만 import하면 ~1.1 kB이며 반응형 스토어 전체가 번들에서 제거됩니다. size-limit으로 CI에서 예산을 강제합니다. -- **하이브리드 반응성** — `type`/`os`는 세션 동안 고정(UA 사실은 리로드 없이 변하지 않음), `isTouchPrimary`와 `orientation`은 `matchMedia` 리스너로 실시간 갱신 — 폴더블, DeX 도킹, iPad Stage Manager까지 커버. -- **실브라우저 검증** — 76개 단위 테스트에 더해, Playwright E2E 매트릭스(iPhone 15, iPad Pro, Galaxy S24, Galaxy Tab S9, 데스크톱 Chrome/Safari)가 실제 Chromium/WebKit 엔진에서 판별 결과와 hydration 에러 0건을 검증합니다. +```tsx +const { type, os, isMobile, isTablet, isDesktop } = useDevice(); -### react-device-detect는요? +// type → 'mobile' | 'tablet' | 'desktop' +// os → 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown' +``` -[react-device-detect](https://www.npmjs.com/package/react-device-detect)는 import 시점에 UA로 상수를 계산해서 SSR에서 크래시하거나 mismatch가 나고, iPad를 데스크톱으로 오판하며, 값이 갱신되지 않고, tree-shaking이 불가능한 ~13 kB gzip을 항상 배송합니다. 2023년 이후 유지보수가 중단됐고, 파서 의존성(ua-parser-js v2)이 AGPL로 전환되어 현대화가 막혀 있습니다. `react-device-check`는 오늘의 플랫폼 현실에 맞춰 설계된, 유지보수되는 MIT 대안입니다. +화면 방향처럼 도중에 바뀌는 값도 함께 옵니다. 전체 목록은 [API 레퍼런스](#api-레퍼런스)에 있습니다. -## 기능 +꺼내 쓰는 건 이렇게 간단합니다. 어려운 쪽은 저 값을 정확하게 만드는 일이고, 아래 셋이 대표적인 경우입니다. -- ✅ `useDevice()` — 반응형 필드를 포함한 전체 기기 스냅샷 -- ✅ `useDeviceType()` / `useIsMobile()` / `useIsTablet()` / `useIsDesktop()` — 정적, 리스너 없음, 최대 tree-shaking -- ✅ `useOS()` — `'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown'` -- ✅ `detectDevice()` — React 없이 쓸 수 있는 순수 엔진 (서버, vanilla JS) -- ✅ iPad 위장 해제, 안드로이드 태블릿 규칙, Samsung DeX, UA 축소 시대 대응 -- ✅ SSR-safe: Next.js App Router/Pages Router, Remix 등 어디서나 -- ✅ React 17, 18, 19 지원 -- ✅ TypeScript 우선, 의존성 0개, MIT +**iPad 사용자에게 데스크톱 화면이 나갑니다.** +브라우저는 요청할 때마다 User-Agent(줄여서 UA) 문자열을 함께 보냅니다. 그런데 iPadOS 13부터 iPad는 이 문자열에 자신을 Mac이라고 적어 보냅니다. UA만 읽는 라이브러리는 여기에 그대로 속습니다. -## 설치 +→ UA와 함께 `maxTouchPoints`를 봅니다. 진짜 Mac은 0을 보고하고 iPad는 5를 보고하므로, "Mac인데 손가락 다섯 개가 닿는다"면 iPad입니다. -```bash -npm install react-device-check -``` +**안드로이드에서 폰과 태블릿이 구분되지 않습니다.** +Chrome이 UA에서 모델명을 지운 뒤로 모든 안드로이드 기기가 `K`라고만 보고합니다. 화면 크기로 짐작하는 방법은 사용자가 창을 줄이는 순간 틀립니다. -```bash -yarn add react-device-check -``` +→ Chrome 계열 브라우저는 UA 말고도 Client Hints라는 별도 정보를 제공합니다. 이쪽은 모델명 삭제와 무관하게 폰인지 아닌지를 알려줍니다. 이 값이 없는 브라우저에서는 UA에 `Mobile` 표시가 있는지로 갈라내는데, 구글이 안내하는 공식 방법입니다. + +**Next.js 콘솔에 hydration 에러가 쌓입니다.** +서버에서 HTML을 미리 만들 때는 접속자가 어떤 기기인지 알 수 없습니다. 반면 브라우저는 압니다. 그래서 서버가 보낸 HTML과 브라우저가 처음 그린 화면이 어긋나고, React가 이를 에러로 보고합니다. (hydration은 서버가 만들어 둔 HTML을 브라우저에서 React가 이어받는 과정입니다.) + +→ 첫 화면에서는 서버와 브라우저가 똑같이 `desktop` / `unknown`을 씁니다. 어긋날 값 자체가 없으니 에러도 없습니다. 진짜 기기 정보는 그 직후 렌더 한 번으로 채워집니다. + +근거와 예외는 [판별 원리](#판별-원리)와 [판별하지 못하는 것](#이런-건-판별하지-못합니다)에 자세히 적어 두었습니다. + +## react-device-detect와 비교 + +[react-device-detect](https://www.npmjs.com/package/react-device-detect)는 import 시점에 UA를 읽어 상수를 만듭니다. 그래서 SSR에서 크래시하거나 mismatch를 냅니다. iPad는 데스크톱으로 잘못 잡습니다. 한번 계산한 값은 갱신되지 않고, 쓰지 않는 코드를 덜어낼 수 없어 ~13 kB(gzip)을 언제나 통째로 내려보냅니다. 2023년 이후로 유지보수가 멈췄고, 파서 의존성인 ua-parser-js v2가 AGPL로 바뀌면서 현대화 길도 막혔습니다. + +Client Hints와 UA 파싱을 모두 갖춘 라이브러리는 사실상 없습니다. `react-device-check`는 지금의 플랫폼 현실에 맞춰 새로 설계한 MIT 대안입니다. + +## 설치 ```bash -pnpm add react-device-check +npm install react-device-check +# yarn add react-device-check +# pnpm add react-device-check ``` ## 빠른 시작 @@ -70,7 +76,7 @@ function App() { } ``` -값 하나만 필요하면 그 훅만 import하세요 — 나머지는 tree-shaking으로 제거됩니다: +값 하나만 필요하다면 그 훅만 가져오면 됩니다. ```tsx import { useIsMobile, useOS } from 'react-device-check'; @@ -85,24 +91,28 @@ function DownloadButton() { } ``` +쓰지 않는 코드는 빌드할 때 번들에서 빠집니다. `useIsMobile` 하나만 쓰면 ~1.1 kB이고, 화면 회전 같은 실시간 변화를 감시하는 코드는 아예 포함되지 않습니다. 이 크기는 CI에서 size-limit이 확인합니다. + ## API 레퍼런스 ### `useDevice(): DeviceInfo` 전체 기기 스냅샷을 반환하고 반응형 변경을 구독합니다. -| 필드 | 타입 | 수명 | 설명 | -| ---------------- | -------------------------------------------------------------------- | ------ | ----------------------------------------------------------- | -| `type` | `'mobile' \| 'tablet' \| 'desktop'` | 정적 | 기기 클래스 | -| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | 정적 | OS 계열 | -| `isMobile` | `boolean` | 정적 | `type === 'mobile'` 축약 | -| `isTablet` | `boolean` | 정적 | `type === 'tablet'` 축약 | -| `isDesktop` | `boolean` | 정적 | `type === 'desktop'` 축약 | -| `isTouchPrimary` | `boolean` | 반응형 | `(pointer: coarse)` — 마우스 연결 시(DeX, iPad) 실시간 전환 | -| `orientation` | `'portrait' \| 'landscape'` | 반응형 | 뷰포트 방향, 회전 시 갱신 | -| `isHydrated` | `boolean` | — | 서버·hydration 첫 페인트에서 `false`, 직후 `true` | +| 필드 | 타입 | 수명 | 설명 | +| ---------------- | -------------------------------------------------------------------- | -------- | ------------------------------------------------------------- | +| `type` | `'mobile' \| 'tablet' \| 'desktop'` | 정적 | 기기 클래스 | +| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | 정적 | OS 계열 | +| `isMobile` | `boolean` | 정적 | `type === 'mobile'` 축약 | +| `isTablet` | `boolean` | 정적 | `type === 'tablet'` 축약 | +| `isDesktop` | `boolean` | 정적 | `type === 'desktop'` 축약 | +| `isTouchPrimary` | `boolean` | 반응형 | `(pointer: coarse)`. 마우스를 연결하면(DeX, iPad) 실시간 전환 | +| `orientation` | `'portrait' \| 'landscape'` | 반응형 | 뷰포트 방향, 회전하면 갱신 | +| `isHydrated` | `boolean` | 1회 전환 | 서버와 hydration 첫 페인트에서 `false`, 직후 `true` | -> **참고:** `type`과 `os`는 의도적으로 세션당 고정입니다. UA 사실은 페이지 리로드 없이 변하지 않으며, 고정 유지가 UI 흔들림을 방지합니다. 뷰포트 의존적인 것은 반응형 필드(또는 CSS)를 사용하세요. +**정적**은 페이지를 새로 열기 전까지 값이 고정된다는 뜻이고, **반응형**은 상황이 바뀌면 다시 렌더된다는 뜻입니다. + +> **참고:** `type`과 `os`를 세션당 고정으로 둔 것은 의도한 설계입니다. UA가 알려주는 사실은 페이지를 새로 열기 전까지 바뀌지 않고, 값을 붙박아 두어야 UI가 흔들리지 않습니다. 뷰포트에 따라 달라져야 하는 것은 반응형 필드나 CSS로 처리하세요. ### 정적 훅 @@ -114,11 +124,11 @@ useIsTablet(): boolean useIsDesktop(): boolean ``` -미디어 리스너를 전혀 부착하지 않습니다. 이 훅들만 import하면 반응형 스토어 전체가 번들에서 제거됩니다 (size-limit CI 체크로 강제). +미디어 리스너를 하나도 붙이지 않습니다. 이 훅들만 import하면 반응형 스토어 전체가 번들에서 빠집니다. -### `detectDevice(input?, options?)` — React 불필요 +### `detectDevice(input?, options?)` (React 불필요) -훅 뒤에 있는 순수 엔진입니다. 모든 신호가 주입 가능해서 서버에서도 사용할 수 있습니다: +훅 뒤에 있는 순수 엔진입니다. 모든 값을 주입할 수 있어 서버에서도 그대로 씁니다. ```ts import { detectDevice } from 'react-device-check'; @@ -126,7 +136,7 @@ import { detectDevice } from 'react-device-check'; // 서버(Express, Next.js middleware 등)에서: 요청 UA를 전달. const { type, os } = detectDevice({ ua: req.headers['user-agent'] }); -// 신호 없는 환경을 위한 커스텀 fallback: +// 읽을 값이 없는 환경을 위한 커스텀 fallback: detectDevice(undefined, { fallback: { type: 'mobile' } }); ``` @@ -140,7 +150,7 @@ detectDevice(undefined, { fallback: { type: 'mobile' } }); ### `getNavigatorInput(): DetectionInput | undefined` -위 신호들을 브라우저 전역에서 읽어오는 함수 — 훅이 내부에서 쓰는 것과 동일한 리더입니다. `window`가 없는 환경에서는 `undefined`를 반환합니다: 웹 워커, 그리고 전역 `navigator`를 탑재한 Node 21+가 여기에 해당합니다 (Node의 `navigator.platform`은 **서버 머신**을 반영하므로 기기 판별에 신뢰하면 안 됩니다). `detectDevice`와 조합해 특정 신호만 바꿔볼 때 유용합니다: +위 값들을 브라우저 전역에서 읽어 오는 함수이고, 훅이 내부에서 쓰는 리더와 같습니다. `window`가 없는 환경, 그러니까 웹 워커나 전역 `navigator`를 탑재한 Node 21+에서는 `undefined`를 돌려줍니다. Node의 `navigator.platform`은 **서버 머신**을 가리키므로 기기 판별에 쓰면 안 됩니다. `detectDevice`와 조합하면 특정 값만 바꿔 볼 수 있습니다. ```ts import { detectDevice, getNavigatorInput } from 'react-device-check'; @@ -150,51 +160,67 @@ const result = detectDevice({ ...getNavigatorInput(), screen: undefined }); ## SSR 동작 (Next.js) -서버는 기기를 알 수 없으므로 계약은 다음과 같습니다: +서버는 기기를 알 수 없습니다. 그래서 이런 순서로 동작합니다. ``` -① 서버 렌더 → 동결된 기본값: { type: 'desktop', os: 'unknown', isHydrated: false } +① 서버 렌더 → 고정된 기본값: { type: 'desktop', os: 'unknown', isHydrated: false } ② hydration 페인트 → 동일한 기본값 → 서버·클라이언트 HTML 항상 일치 → hydration 에러 없음 ③ 직후 → 실제 값으로 1회 교정 렌더, isHydrated: true ``` -- 순수 CSR 앱(Vite, CRA)은 ①②를 건너뜁니다 — 첫 렌더부터 정확한 값. -- 첫 페인트에서 추측하면 안 되는 UI는 `isHydrated`로 중립 플레이스홀더를 렌더하세요. -- **레이아웃은 CSS 미디어 쿼리로, 이 훅은 행동 분기용으로** (어떤 SDK를 로드할지, 어떤 플로우를 시작할지, 어디로 리다이렉트할지). 그러면 교정 렌더와 무관하게 CLS가 0으로 유지됩니다. -- 번들에 `'use client'` 배너가 포함되어 있어, React Server Component에서 import하면 알 수 없는 훅 에러 대신 명확한 경계 에러가 발생합니다. +서버와 브라우저가 첫 화면에서 똑같은 값을 쓰기 때문에 둘이 어긋날 일이 없습니다. React 18/19에서 hydration mismatch가 구조적으로 생기지 않는 이유입니다. Next.js App Router와 Pages Router, Remix 어디서나 같습니다. React 17 + SSR만 예외이고, 아래에 적어 두었습니다. + +- 순수 CSR 앱(Vite, CRA)은 ①②를 건너뛰고 첫 렌더부터 정확한 값을 받습니다. +- 첫 페인트에서 추측하면 안 되는 UI는 `isHydrated`를 보고 중립 플레이스홀더를 렌더하세요. +- **레이아웃은 CSS 미디어 쿼리로, 이 훅은 행동 분기용으로** 쓰는 편이 좋습니다. 어떤 SDK를 로드할지, 어떤 플로우를 시작할지, 어디로 리다이렉트할지 같은 것들입니다. 그러면 교정 렌더와 무관하게 CLS가 0으로 유지됩니다. +- 번들에 `'use client'` 배너가 들어 있어서, React Server Component에서 import하면 알 수 없는 훅 에러 대신 명확한 경계 에러가 납니다. ## 판별 원리 -신호를 우선순위로 융합합니다: +기기를 알아낼 수 있는 값 세 가지를 순서대로 확인합니다. 같은 값이 들어오면 언제나 같은 답이 나옵니다. + +**1. User-Agent Client Hints** (`navigator.userAgentData`, Chrome 계열만 제공) + +UA가 한 덩어리 문자열인 것과 달리, 이쪽은 "모바일인가", "어떤 OS인가"가 항목별로 따로 옵니다. Chrome이 UA에서 모델명을 지운 것과도 무관합니다. 그래서 이 값이 있으면 가장 먼저 믿습니다. + +안드로이드에서 폰과 태블릿은 `mobile` 항목으로 갈립니다. 안드로이드인데 `mobile`이 `false`면 태블릿이라는 것이 구글이 안내하는 규칙입니다. + +**2. UA 문자열** (Safari, Firefox, 웹뷰) + +Client Hints를 주지 않는 브라우저에서만 씁니다. 문자열에 `iPhone`이나 `iPad`가 들어 있는지, 안드로이드라면 `Mobi`라는 표시가 있는지, 그 밖에는 `Windows`·`Mac`·`Linux` 중 무엇인지를 봅니다. `Mobi`가 있으면 폰, 없으면 태블릿입니다. + +**3. `maxTouchPoints` 교차검증** + +Mac을 자처하는 iPad가 여기서 걸러집니다. 진짜 Mac은 동시에 인식하는 터치 지점이 0개인데 iPad는 5개입니다. 그래서 "Mac이라는데 터치 지점이 1개보다 많다"면 데스크톱 UA를 쓰는 Apple 터치 기기입니다. + +그게 iPad인지 데스크톱 모드를 켠 iPhone인지는 화면의 짧은 쪽 길이로 나눕니다. 가장 큰 iPhone이 440px 언저리, 가장 작은 iPad가 744px이라 두 범위가 겹치지 않습니다. + +카카오톡·인스타그램 같은 인앱 웹뷰와 옛날 UA 문자열도 모두 이 순서를 그대로 지납니다. + +## 이런 건 판별하지 못합니다 + +아래는 `react-device-check`가 틀리게 답하거나 아예 알 수 없는 경우입니다. 미리 알고 쓰시라고 모아 두었습니다. -1. **User-Agent Client Hints** (`navigator.userAgentData`, Chromium 전용) — 존재하면 권위 신호. UA 동결에 면역. 안드로이드 태블릿은 공식 `Mobile` 토큰 규칙으로 폰과 구분. -2. **UA 문자열** (Safari, Firefox, 웹뷰) — `iPhone`/`iPad` 토큰, 안드로이드 `Mobi` 규칙, `Windows`/`Mac`/`Linux` 계열. -3. **`maxTouchPoints` 교차검증** — 터치포인트가 1보다 큰 "Mac"은 데스크톱 UA로 위장한 Apple 터치 기기(iPadOS 13+ 기본값). 화면 최단변으로 데스크톱 모드 iPhone과 iPad를 구분. +**일부러 이렇게 정한 것** -## 알려진 한계 +- Samsung DeX는 `desktop`입니다. 폰이지만 데스크톱처럼 쓰는 모드라서, 삼성 공식 가이드를 따랐습니다. 이때 `SamsungBrowser` 표시가 보이면 `os`는 `android`로 둡니다. +- 윈도우 터치 노트북과 Surface도 `desktop`입니다. 터치가 된다고 노트북이 태블릿이 되지는 않습니다. 구글·마이크로소프트 가이드와 같은 입장입니다. +- TV는 `desktop`입니다. Android TV, Fire TV, BRAVIA, Chromecast를 최대한 알아내서 `desktop`으로 보냅니다. mobile·tablet·desktop 셋 중에서는 리모컨으로 멀리서 쓰는 화면에 desktop이 가장 가깝습니다. +- ChromeOS는 `os: 'linux'`, visionOS Safari는 `tablet`/`ios`로 나옵니다. +- 봇은 자신이 흉내 내는 기기를 그대로 따라갑니다. Googlebot 스마트폰이면 `mobile`/`android`입니다. -정직한 판별이란 판별할 수 없는 것을 문서화하는 것입니다: +**알아낼 방법이 없는 것** -- **SSR은 데스크톱 모드 iPad를 볼 수 없음** — 데스크톱 모드 iPad의 요청은 Mac과 바이트 단위로 동일합니다. 서버는 fallback을 렌더하고, 클라이언트가 hydration 직후 교정합니다. -- **iPhone "데스크톱 웹사이트 요청"**은 화면 크기로 언마스킹하며, 화면 정보가 없으면 `tablet`/`ios`로 보고됩니다. -- **Samsung DeX는 `desktop`으로 보고** (Samsung 공식 가이드), `SamsungBrowser` 토큰이 보이면 `os: 'android'`. -- **Chrome 안드로이드 "데스크톱 사이트 요청"**은 `desktop`/`linux` — 기능이 설계대로 동작하는 것이며, 실제 리눅스 데스크톱과 구분 불가능합니다. -- **폴더블**(갤럭시 폴드/플립)은 양쪽 화면 모두 `mobile` — UA 신호가 존재하지 않습니다. 폴드 대응 UI는 뷰포트 기반 레이아웃을 사용하세요. -- **윈도우 터치 노트북과 Surface는 `desktop`** — 터치 능력은 기기 정체성이 아닙니다 (Google·Microsoft 가이드와 일치). -- **ChromeOS는 `os: 'linux'`**, visionOS Safari는 `tablet`/`ios`로 보고됩니다. -- **TV는 `desktop`으로 보고** — Android TV / Fire TV / BRAVIA / Chromecast UA는 best-effort TV 마커로 감지해 `desktop`으로 매핑합니다. 3분류 택소노미에서 터치 없는 10-foot UI에 가장 가까운 값입니다. -- **HarmonyOS NEXT는 `os: 'unknown'`** — ArkWeb의 `Phone`/`Tablet` 토큰으로 `type`은 정확히 판별하지만, v1 OS 유니언에 HarmonyOS 값이 없습니다. -- **봇**은 에뮬레이션하는 기기대로 분류됩니다 (Googlebot 스마트폰 → `mobile`/`android`). -- **UA 스푸핑에는 무방비** — 클라이언트 사이드 판별은 결정론적일 수는 있어도 적대적 환경을 이길 수는 없습니다. -- **React 17 + SSR은 hydration 경고가 기록될 수 있음** — React 17에는 `useSyncExternalStore`가 없어 내부 폴백(공식 shim과 동일한 한계)이 hydration 첫 페인트에 클라이언트 스냅샷을 렌더합니다. React 18/19는 구조적으로 mismatch가 없고, React 17 CSR은 영향이 없습니다. +- 서버에서는 데스크톱 모드 iPad를 알아볼 수 없습니다. 요청 내용이 Mac과 한 글자도 다르지 않기 때문입니다. 서버는 일단 기본값을 보내고, 브라우저가 넘겨받은 직후 바로잡습니다. +- iPhone에서 "데스크톱 웹사이트 요청"을 켰는데 화면 크기까지 알 수 없으면 `tablet`/`ios`로 나옵니다. 화면 크기가 있으면 폰으로 제대로 잡습니다. +- Chrome 안드로이드의 "데스크톱 사이트 요청"은 `desktop`/`linux`가 됩니다. 브라우저가 의도적으로 리눅스 데스크톱인 척하는 것이라 진짜와 구분할 방법이 없습니다. +- 폴더블(갤럭시 폴드/플립)은 펼쳐도 접어도 `mobile`입니다. 지금 접혀 있는지 알려주는 값이 아예 없습니다. 펼침 상태에 맞춰야 하는 화면은 CSS 미디어 쿼리로 만드세요. +- UA를 일부러 바꿔서 접속하는 것은 막지 못합니다. 받은 값에 일관된 답을 낼 뿐, 작정하고 속이는 상대를 가려내지는 못합니다. -## 로드맵 +**아직 지원하지 않는 것** -- 인앱 브라우저 판별 (카카오톡, 네이버, 인스타그램, 라인, 위챗, 일반 웹뷰) + 외부 브라우저 탈출 헬퍼 -- `` — 서버에서 파싱한 UA를 주입해 첫 페인트부터 정확한 값 -- 프레임워크 없이 쓰는 `react-device-check/core` subpath -- 비동기 `getHighEntropyValues`/`formFactors` 정밀화 (크롬북 태블릿) -- 브라우저명 판별 +- HarmonyOS NEXT는 `os`가 `'unknown'`으로 나옵니다. `type`은 정확합니다. v1의 `os` 목록에 HarmonyOS를 아직 넣지 않았습니다. +- React 17에서 서버 렌더링을 쓰면 hydration 경고가 찍힐 수 있습니다. React 17에는 이 훅이 쓰는 `useSyncExternalStore`가 없어서, 대신 넣어둔 코드가 첫 화면부터 브라우저 값을 그려버립니다. React 공식 대체 구현도 똑같은 한계를 갖고 있습니다. React 18/19에서는 생기지 않고, React 17이어도 서버 렌더링을 쓰지 않으면 문제없습니다. ## 로컬 개발 @@ -210,13 +236,13 @@ pnpm e2e # 두 예제에 대한 Playwright 기기 매트릭스 E2E ## 테스트 -- **85개 단위 테스트** — 실제 UA 문자열 48개 픽스처 매트릭스 포함 (동결된 Chrome UA, iOS 26, iPad 데스크톱 모드, DeX, Firefox 태블릿, 카카오톡 웹뷰, Fire TV, Opera Mini, HarmonyOS NEXT 등) -- **Playwright E2E** — 6개 기기 프로필에서 실제 Chromium/WebKit로 판별 결과, 서버 원본 HTML, hydration 에러 0건 검증 -- CI는 React 17/18/19 호환 레그, `@arethetypeswrong/cli`, size-limit 예산을 실행 +- 단위 테스트 85개. 실제 UA 문자열 48개를 픽스처 매트릭스로 돌립니다(동결된 Chrome UA, iOS 26, iPad 데스크톱 모드, DeX, Firefox 태블릿, 카카오톡 웹뷰, Fire TV, Opera Mini, HarmonyOS NEXT 등). +- Playwright E2E는 기기 프로필 6개를 실제 Chromium/WebKit로 띄워 판별 결과와 서버 원본 HTML, hydration 에러 0건을 확인합니다. +- CI는 React 17/18/19 호환 레그, `@arethetypeswrong/cli`, size-limit 예산을 실행합니다. ## 기여 -이슈와 풀 리퀘스트를 환영합니다! 제출 전에 `pnpm lint && pnpm typecheck && pnpm test`를 실행해주세요. +이슈와 풀 리퀘스트를 환영합니다. 제출 전에 `pnpm lint && pnpm typecheck && pnpm test`를 실행해 주세요. ## 라이선스 diff --git a/README.md b/README.md index 255fd71..f3cd298 100644 --- a/README.md +++ b/README.md @@ -8,46 +8,52 @@ **Website / live demo**: [react-device-check-site.vercel.app](https://react-device-check-site.vercel.app) -**Lightweight, accurate React hooks for device detection — in any React app, CSR or SSR.** Know whether your user is on a phone, tablet, or desktop — and which OS — with zero dependencies, ~1.5 kB (min+brotli) for everything, and no hydration errors in Next.js. +**A React hook that tells you whether your user is on a phone, tablet, or desktop, and which OS they run.** No dependencies, and ~1.5 kB (min+brotli) even if you use all of it. It works on React 17, 18, and 19, ships its own type definitions, and does not break in Next.js or anywhere else that builds HTML on the server first. -Detecting devices in 2026 is harder than it looks: iPads masquerade as Macs, Chrome froze its User-Agent string (every Android model reports `K`), Samsung DeX sends a desktop Linux UA from a phone, and iOS 26 froze its OS version token forever. `react-device-check` fuses the signals that still work — User-Agent Client Hints, the UA string, and `maxTouchPoints` cross-checks — into a small deterministic decision tree instead of a regex database. +## What this solves -## Why react-device-check? +One hook call gives you the device class and the OS. -- **Accurate where others fail** — correctly reports iPads as tablets even though iPadOS 13+ sends a macOS desktop UA (`MacIntel` + multitouch unmasking); classifies Android tablets via the official `Mobile`-token rule; handles Samsung DeX, WebViews (KakaoTalk, Instagram, …), and legacy UAs. -- **Client Hints first** — trusts `navigator.userAgentData` on Chromium (immune to UA freezing), falls back to UA parsing everywhere else. No library with meaningful adoption does both. -- **SSR-safe by construction** — the server render and hydration first paint always agree, so React never logs a hydration mismatch on React 18/19 (React 17 + SSR is a documented exception — see known limitations). The hook corrects itself in one post-hydration render. -- **Tiny and tree-shakeable** — zero runtime dependencies, `sideEffects: false`, dual ESM/CJS. Importing only `useIsMobile` ships ~1.1 kB and drops the reactive store entirely. Budgets are enforced in CI with size-limit. -- **Hybrid reactivity** — `type`/`os` are stable for the session (UA facts can't change without a reload), while `isTouchPrimary` and `orientation` update live via `matchMedia` listeners — covering foldables, DeX docking, and iPad Stage Manager. -- **Proven in real browsers** — beyond 76 unit tests, a Playwright E2E matrix (iPhone 15, iPad Pro, Galaxy S24, Galaxy Tab S9, desktop Chrome/Safari) verifies detection and zero hydration errors against real Chromium and WebKit engines. +```tsx +const { type, os, isMobile, isTablet, isDesktop } = useDevice(); -### What about react-device-detect? +// type → 'mobile' | 'tablet' | 'desktop' +// os → 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown' +``` -[react-device-detect](https://www.npmjs.com/package/react-device-detect) computes import-time constants from the UA, which crashes or mismatches under SSR, misreports iPads as desktops, never updates, and ships ~13 kB gzip that cannot be tree-shaken. It has been unmaintained since 2023, and its parser dependency (ua-parser-js v2) moved to AGPL — blocking modernization. `react-device-check` is a maintained MIT replacement designed around today's platform realities. +Values that change while the page is open, such as orientation, come along too. The full list is in the [API reference](#api-reference). -## Features +Reading them is that simple. The hard part is making them correct, and these three cases are why. -- ✅ `useDevice()` — full device snapshot with live reactive fields -- ✅ `useDeviceType()` / `useIsMobile()` / `useIsTablet()` / `useIsDesktop()` — static, listener-free, maximally tree-shakeable -- ✅ `useOS()` — `'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown'` -- ✅ `detectDevice()` — the pure engine, usable without React (servers, vanilla JS) -- ✅ iPad-as-Mac unmasking, Android tablet rule, Samsung DeX, UA-reduction era support -- ✅ SSR-safe: works in Next.js App Router/Pages Router, Remix, anywhere -- ✅ React 17, 18, and 19 support -- ✅ TypeScript-first, zero dependencies, MIT +**Your iPad users get the desktop layout.** +Browsers send a User-Agent (UA) string with every request. Since iPadOS 13, an iPad writes "Mac" into that string. Any library that only reads the UA believes it. -## Installation +→ Read `maxTouchPoints` alongside the UA. A real Mac reports 0 and an iPad reports 5, so a "Mac" that five fingers can touch is an iPad. -```bash -npm install react-device-check -``` +**Android phones and tablets look identical.** +Chrome stripped the model name out of the UA, so every Android device now reports `K`. Guessing from screen size breaks the moment someone resizes the window. -```bash -yarn add react-device-check -``` +→ Chromium browsers also expose Client Hints, a separate set of fields that still answers "is this a phone" no matter what the UA hides. Where those are missing, the UA's `Mobile` marker splits phones from tablets, which is the method Google documents. + +**Your Next.js console fills up with hydration errors.** +When HTML is built on the server, there is no way to know what device is asking for it. The browser, of course, knows. So the server's HTML and the browser's first paint disagree, and React reports it as an error. (Hydration is the step where React takes over the HTML the server already produced.) + +→ On that first paint, server and browser both use `desktop` / `unknown`. There is nothing that can disagree, so there is no error. The real values land one render later. + +The reasoning and the exceptions are spelled out in [how detection works](#how-detection-works) and [known limitations](#known-limitations). + +## Compared with react-device-detect + +[react-device-detect](https://www.npmjs.com/package/react-device-detect) computes constants from the UA at import time. That crashes or mismatches under SSR. It also calls iPads desktops. Values never refresh, and because unused parts cannot be dropped it always ships ~13 kB gzip. Maintenance stopped in 2023, and its parser dependency (ua-parser-js v2) moved to AGPL, which blocks modernization. + +Almost no library does both Client Hints and UA parsing. `react-device-check` is a maintained MIT alternative designed for how platforms actually behave today. + +## Installation ```bash -pnpm add react-device-check +npm install react-device-check +# yarn add react-device-check +# pnpm add react-device-check ``` ## Quick Start @@ -70,7 +76,7 @@ function App() { } ``` -Need just one value? Import just that hook — the rest of the library tree-shakes away: +Need just one value? Import just that hook. ```tsx import { useIsMobile, useOS } from 'react-device-check'; @@ -85,24 +91,28 @@ function DownloadButton() { } ``` +Whatever you do not import is dropped at build time. `useIsMobile` on its own costs ~1.1 kB, and the code that watches for live changes such as rotation is never included at all. size-limit checks these numbers in CI. + ## API Reference ### `useDevice(): DeviceInfo` Returns the full device snapshot and subscribes to reactive changes. -| Field | Type | Lifetime | Description | -| ---------------- | -------------------------------------------------------------------- | -------- | --------------------------------------------------------------------- | -| `type` | `'mobile' \| 'tablet' \| 'desktop'` | static | Device class | -| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | static | OS family | -| `isMobile` | `boolean` | static | Sugar for `type === 'mobile'` | -| `isTablet` | `boolean` | static | Sugar for `type === 'tablet'` | -| `isDesktop` | `boolean` | static | Sugar for `type === 'desktop'` | -| `isTouchPrimary` | `boolean` | reactive | `(pointer: coarse)` — flips live when a mouse is attached (DeX, iPad) | -| `orientation` | `'portrait' \| 'landscape'` | reactive | Viewport orientation, updates on rotation | -| `isHydrated` | `boolean` | — | `false` on the server and hydration first paint, `true` right after | +| Field | Type | Lifetime | Notes | +| ---------------- | -------------------------------------------------------------------- | -------- | -------------------------------------------------------------------- | +| `type` | `'mobile' \| 'tablet' \| 'desktop'` | static | Device class | +| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | static | OS family | +| `isMobile` | `boolean` | static | Shorthand for `type === 'mobile'` | +| `isTablet` | `boolean` | static | Shorthand for `type === 'tablet'` | +| `isDesktop` | `boolean` | static | Shorthand for `type === 'desktop'` | +| `isTouchPrimary` | `boolean` | reactive | `(pointer: coarse)`. Flips live when a mouse is attached (DeX, iPad) | +| `orientation` | `'portrait' \| 'landscape'` | reactive | Viewport orientation, updates on rotation | +| `isHydrated` | `boolean` | one-shot | `false` on the server and hydration first paint, `true` right after | + +**Static** means the value is pinned until the page reloads. **Reactive** means a change re-renders your component. -> **Note:** `type` and `os` are intentionally static per session. User-agent facts cannot change without a page load, and keeping them stable prevents UI flapping. Use the reactive fields (or CSS) for anything viewport-dependent. +> **Note:** `type` and `os` are deliberately fixed per session. What the UA tells you does not change until the page reloads, and pinning the values keeps the UI from jumping. Use the reactive fields (or CSS) for anything that depends on the viewport. ### Static hooks @@ -114,23 +124,23 @@ useIsTablet(): boolean useIsDesktop(): boolean ``` -These never attach media listeners. Importing only these drops the whole reactive store from your bundle (enforced by a size-limit CI check). +These attach no media listeners at all. Import only these and the whole reactive store leaves the bundle. -### `detectDevice(input?, options?)` — no React required +### `detectDevice(input?, options?)` (no React required) -The pure engine behind the hooks. All signals are injectable, which also makes it usable on servers: +The pure engine behind the hooks. Every signal is injectable, so it runs on the server unchanged. ```ts import { detectDevice } from 'react-device-check'; -// On a server (Express, Next.js middleware, etc.): pass the request UA. +// On a server (Express, Next.js middleware, ...): pass the request UA. const { type, os } = detectDevice({ ua: req.headers['user-agent'] }); -// With a custom fallback for signal-less environments: +// Custom fallback for environments with no signals: detectDevice(undefined, { fallback: { type: 'mobile' } }); ``` -| `DetectionInput` field | Read from (client) | +| `DetectionInput` field | Read from, on the client | | ---------------------- | -------------------------- | | `ua` | `navigator.userAgent` | | `uaData` | `navigator.userAgentData` | @@ -140,7 +150,7 @@ detectDevice(undefined, { fallback: { type: 'mobile' } }); ### `getNavigatorInput(): DetectionInput | undefined` -Reads all of the above signals from the browser globals — the same reader the hooks use internally. Returns `undefined` outside a `window` environment: that includes web workers and Node 21+, which ships a global `navigator` whose `platform` reflects the **server machine** and must not be trusted for device detection. Useful for composing with `detectDevice` when you want to tweak one signal: +Reads all of the above from the browser globals, using the same reader the hooks use internally. It returns `undefined` wherever there is no `window`, which means web workers and Node 21+ with its global `navigator`. Node's `navigator.platform` describes the **server machine**, so it must never be trusted for device detection. Pair this with `detectDevice` when you want to override one signal. ```ts import { detectDevice, getNavigatorInput } from 'react-device-check'; @@ -150,51 +160,67 @@ const result = detectDevice({ ...getNavigatorInput(), screen: undefined }); ## SSR behavior (Next.js) -The server cannot know the device, so the contract is: +The server cannot know the device, so the sequence goes like this. ``` -① Server render → frozen default: { type: 'desktop', os: 'unknown', isHydrated: false } -② Hydration paint → same default → server and client HTML always match → no hydration error -③ Right after → one correction render with the real values, isHydrated: true +① server render → frozen default: { type: 'desktop', os: 'unknown', isHydrated: false } +② hydration paint → same default → server and client HTML always match → no hydration error +③ right after → one correcting render with the real values, isHydrated: true ``` -- Pure CSR apps (Vite, CRA) skip ①② — values are correct from the very first render. -- Use `isHydrated` to render neutral placeholders when the first paint must not guess. -- **Layout should come from CSS media queries; use this hook for behavior** (which SDK to load, which flow to start, where to redirect). That keeps CLS at zero regardless of the correction render. -- The bundle ships a `'use client'` banner, so importing it from a React Server Component fails with a clear boundary error instead of a cryptic hooks error. +Because the server and the browser use the same value for that first paint, the two can never disagree. That is why a hydration mismatch cannot happen on React 18/19. The same holds in Next.js App Router and Pages Router, Remix, and anywhere else. React 17 + SSR is the one exception, written up under known limitations. + +- Pure CSR apps (Vite, CRA) skip ①② and get correct values from the very first render. +- For UI that must not guess on the first paint, check `isHydrated` and render a neutral placeholder. +- **Use CSS media queries for layout and this hook for behavior**, meaning which SDK to load, which flow to start, where to redirect. Do that and CLS stays at zero no matter what the correcting render does. +- The bundle carries a `'use client'` banner, so importing it from a React Server Component raises a clear boundary error instead of a cryptic invalid-hook error. ## How detection works -Signals are fused in priority order: +Three values can identify a device, and they are checked in order. The same input always produces the same answer. + +**1. User-Agent Client Hints** (`navigator.userAgentData`, Chromium only) + +Where the UA is one long string, these arrive as separate fields: is this mobile, which OS. Chrome stripping the model name does not touch them. So whenever they exist, they are believed first. + +On Android, phones and tablets split on the `mobile` field. An Android device reporting `mobile: false` is a tablet, which is the rule Google documents. + +**2. UA string** (Safari, Firefox, webviews) + +Used only in browsers that do not offer Client Hints. Does the string contain `iPhone` or `iPad`; on Android, is the `Mobi` marker there; otherwise is it `Windows`, `Mac`, or `Linux`. With `Mobi` it is a phone, without it a tablet. + +**3. `maxTouchPoints` cross-check** -1. **User-Agent Client Hints** (`navigator.userAgentData`, Chromium only) — authoritative when present; immune to UA freezing. Android tablets are split from phones via the official `Mobile`-token rule. -2. **UA string** (Safari, Firefox, WebViews) — `iPhone`/`iPad` tokens, the Android `Mobi` rule, `Windows`/`Mac`/`Linux` families. -3. **`maxTouchPoints` cross-check** — a "Mac" with more than one touch point is an Apple touch device masquerading via a desktop UA (iPadOS 13+ default). The screen's shortest side separates desktop-mode iPhones from iPads. +This is where an iPad claiming to be a Mac gets caught. A real Mac registers 0 simultaneous touch points; an iPad registers 5. So a "Mac" reporting more than one touch point is an Apple touch device wearing a desktop UA. + +Whether that is an iPad or an iPhone in desktop mode comes down to the screen's shorter edge. The largest iPhone sits around 440px and the smallest iPad at 744px, so the two ranges never overlap. + +In-app webviews such as KakaoTalk and Instagram, and older UA strings, all run through this same order. ## Known limitations -Honest detection means documenting what cannot be detected: - -- **SSR cannot see desktop-mode iPads** — a desktop-mode iPad request is byte-identical to a Mac. The server renders the fallback; the client corrects it right after hydration. -- **iPhone "Request Desktop Website"** is unmasked via screen size; if screen dimensions are unavailable it reports `tablet`/`ios`. -- **Samsung DeX reports `desktop`** (Samsung's own guidance) with `os: 'android'` when the `SamsungBrowser` token is visible. -- **Chrome Android "Request desktop site"** reports `desktop`/`linux` — that is the feature working as designed; it is indistinguishable from a real Linux desktop. -- **Foldables** (Galaxy Fold/Flip) report `mobile` on both screens — no UA signal exists. Use viewport-based layout for fold-aware UI. -- **Windows touch laptops and Surface report `desktop`** — touch capability is not device identity (matching Google's and Microsoft's guidance). -- **ChromeOS reports `os: 'linux'`**; visionOS Safari reports `tablet`/`ios`. -- **TVs report `desktop`** — Android TV / Fire TV / BRAVIA / Chromecast UAs are detected via best-effort TV markers and mapped to `desktop`, the closest fit in a three-way taxonomy for a 10-foot no-touch UI. -- **HarmonyOS NEXT reports `os: 'unknown'`** — ArkWeb's `Phone`/`Tablet` tokens drive the correct `type`, but the v1 OS union has no HarmonyOS value. -- **Bots** classify as whatever device they emulate (Googlebot smartphone → `mobile`/`android`). -- **UA spoofing wins** — client-side detection can only be deterministic, not adversarial. -- **React 17 + SSR may log a hydration warning** — React 17 has no `useSyncExternalStore`, and the internal fallback (like the official shim) renders the client snapshot on the hydration first paint. React 18/19 are mismatch-free by construction; React 17 CSR is unaffected. - -## Roadmap - -- In-app browser detection (KakaoTalk, Naver, Instagram, Line, WeChat, generic WebView) with external-browser escape helpers -- `` — feed a server-parsed UA for a correct first paint -- `react-device-check/core` subpath for framework-free usage -- Async `getHighEntropyValues`/`formFactors` refinement (Chromebook tablets) -- Browser name detection +These are the cases where `react-device-check` answers wrongly or cannot know at all. They are collected here so you meet them on purpose rather than in production. + +**Decided this way on purpose** + +- Samsung DeX reports `desktop`. It is a phone being used like a desktop, and Samsung's own guidance says to treat it as one. When the `SamsungBrowser` marker is present, `os` stays `android`. +- Windows touch laptops and Surface are `desktop` too. Accepting touch does not turn a laptop into a tablet, which is Google's and Microsoft's position as well. +- TVs report `desktop`. Android TV, Fire TV, BRAVIA, and Chromecast are identified as best we can and mapped to `desktop`. Of mobile, tablet, and desktop, a screen you drive with a remote from across the room is closest to desktop. +- ChromeOS reports `os: 'linux'`, and visionOS Safari reports `tablet`/`ios`. +- Bots follow whatever device they imitate. Googlebot Smartphone comes back as `mobile`/`android`. + +**No way to know** + +- A server cannot recognise a desktop-mode iPad. The request is not one byte different from a Mac's. The server sends the default and the browser corrects it the moment it takes over. +- An iPhone with "Request Desktop Website" turned on comes back as `tablet`/`ios` when screen size is also unavailable. Given the screen size, it is correctly caught as a phone. +- Chrome on Android with "Request desktop site" reports `desktop`/`linux`. The browser is deliberately pretending to be a Linux desktop, so there is nothing left to tell them apart by. +- Foldables (Galaxy Fold/Flip) are `mobile` whether open or closed. Nothing reports the current fold state. Build screens that must react to unfolding with CSS media queries. +- Deliberately altered UA strings win. Detection gives a consistent answer for the values it receives, but it cannot catch someone who is lying on purpose. + +**Not supported yet** + +- HarmonyOS NEXT reports `os: 'unknown'`. The `type` is correct. HarmonyOS is simply not in the v1 `os` list yet. +- React 17 with server rendering may log a hydration warning. React 17 lacks `useSyncExternalStore`, so the fallback in its place paints the browser's value from the very first render. React's own official replacement has the same limitation. React 18/19 are unaffected, and React 17 without server rendering is fine too. ## Local development @@ -210,13 +236,13 @@ pnpm e2e # Playwright device-matrix E2E against both examples ## Testing -- **85 unit tests** including a 48-fixture matrix of real-world UA strings (frozen Chrome UAs, iOS 26, iPad desktop mode, DeX, Firefox tablets, KakaoTalk WebView, Fire TV, Opera Mini, HarmonyOS NEXT, …) -- **Playwright E2E** on real Chromium/WebKit across six device profiles, asserting detection results, raw server HTML, and zero hydration errors -- CI runs React 17/18/19 compatibility legs, `@arethetypeswrong/cli`, and size-limit budgets +- 85 unit tests, including a fixture matrix of 48 real UA strings (frozen Chrome UA, iOS 26, iPad desktop mode, DeX, Firefox tablet, KakaoTalk webview, Fire TV, Opera Mini, HarmonyOS NEXT, and more). +- Playwright E2E drives 6 device profiles on real Chromium and WebKit, checking the verdicts, the raw server HTML, and zero hydration errors. +- CI runs React 17/18/19 compatibility legs, `@arethetypeswrong/cli`, and the size-limit budgets. ## Contributing -Issues and pull requests are welcome! Please run `pnpm lint && pnpm typecheck && pnpm test` before submitting. +Issues and pull requests are welcome. Please run `pnpm lint && pnpm typecheck && pnpm test` before submitting. ## License @@ -224,4 +250,4 @@ Issues and pull requests are welcome! Please run `pnpm lint && pnpm typecheck && --- -**Keywords:** react device detection hook, react-device-detect alternative, detect mobile tablet desktop react, iPad detection react, useIsMobile hook, SSR-safe device detection, Next.js device detection, user agent client hints react, react device type hook, zero dependency device detect +**Keywords:** react device detection hook, react-device-detect alternative, detect mobile tablet desktop react, ipad detection react, useIsMobile hook, SSR safe device detection, Next.js device detection, user agent client hints react, react device type hook, zero dependency device detect From cb34c34bb1f3df43beeafe28bd7620a61abed4d9 Mon Sep 17 00:00:00 2001 From: umsungjun Date: Mon, 10 Aug 2026 16:28:20 +0900 Subject: [PATCH 4/4] Rewrite the website copy in plainer language The landing copy assumed the reader already knew hydration, tree-shaking, Client Hints, UA freezing, and the reactive store, and used them without introduction. Terms that carry their own meaning for a web developer are kept as they are: `User-Agent`, `maxTouchPoints`, `hydration`, `Client Hints`. Terms that were only jargon are spelled out, so a 10-foot UI becomes a screen driven with a remote from across the room, and multitouch unmasking becomes the five touch points that give the device away. The Korean copy also drops the writing tells it had accumulated: every sentence ending the same way, subjectless passives, and clauses chained on connective endings. The demo section no longer calls the SSR behaviour a contract, a word that carries none of its English meaning in Korean, and describes the sequence instead. The unit test count is corrected from 76 to 85 to match the suite, same as in the READMEs. Section structure is untouched, since `types.ts` holds en and ko to the same shape. --- website/content/en.ts | 60 +++++++++++++++++++++---------------------- website/content/ko.ts | 58 ++++++++++++++++++++--------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/website/content/en.ts b/website/content/en.ts index 5ae5b32..f15522e 100644 --- a/website/content/en.ts +++ b/website/content/en.ts @@ -7,68 +7,68 @@ export const en: LandingStrings = { }, hero: { badges: ['~1.5 kB min+brotli', 'Zero dependencies', 'React 17–19', 'MIT'], - titlePre: 'Device detection that’s right ', + titlePre: "Device detection that's right ", titleAccent: 'in CSR and SSR', titlePost: '', tagline: - 'In React CSR apps and Next.js SSR alike, know exactly whether your user is on a phone, tablet, or desktop, and which OS. Zero dependencies, iPads unmask themselves, frozen user agents don’t matter, and there is not a single hydration error.', + "Know whether your user is on a phone, tablet, or desktop, and which OS they run. It catches the iPad that writes \"Mac\" into its own user agent, and it does not break in Next.js or anywhere else that builds HTML on the server first. No dependencies.", ctaDemo: 'See it live', ctaGithub: 'GitHub', }, showcase: { overline: 'At a glance', - title: 'Devices lie. The answers don’t.', + title: "Devices lie. The answers don't.", intro: - 'Each screen shows what useDevice() returns on that device — including the ones that lie about themselves.', + 'These are the devices where trusting the User-Agent string gets you the wrong answer.', claimLabel: 'What it claims', verdictLabel: 'What the hook returns', devices: [ { name: 'iPhone 15', caption: - 'With Safari’s “Request Desktop Website” even an iPhone claims to be a Mac. Multitouch plus the screen-size cross-check still says mobile.', + 'With Safari\'s "Request Desktop Website" even an iPhone claims to be a Mac. Multitouch plus the screen-size cross-check still says mobile.', }, { name: 'Galaxy S24', caption: - 'Chrome froze the UA — every Android reports model “K”. Client Hints still nail it.', + 'Chrome stripped the model name out of the UA, so every Android just reports "K". Client Hints, which browsers send separately, still answer it.', }, { name: 'iPad Pro', caption: - 'Masquerades as a Mac since iPadOS 13. Multitouch unmasking says tablet anyway.', + 'Has claimed to be a Mac since iPadOS 13. Five touch points give it away as a tablet anyway.', }, { name: 'iMac', caption: - 'A real Mac sending the exact same UA as the iPad above. The multitouch cross-check (maxTouchPoints: 0) is what tells them apart.', + 'A real Mac sending the exact same UA as the iPad above. One fact separates them: maxTouchPoints is 0.', }, { name: 'Windows touch laptop', caption: - 'A touchscreen doesn’t fool it — touch is only consulted for the Apple masquerade. Stays desktop.', + 'A touchscreen does not change the answer. maxTouchPoints is only consulted to catch devices claiming to be Macs, so this stays desktop.', }, { name: 'Android TV', caption: - 'An Android UA without the Mobile token would normally land in the tablet bucket, but TV markers are checked first — desktop is the best fit for a 10-foot UI.', + 'An Android UA without the Mobile token would normally land in the tablet bucket, but TV markers are checked first. Desktop is the closest fit for a screen you drive with a remote from across the room.', }, ], }, demo: { overline: 'Live demo', - title: 'Watch the SSR contract in action', + title: 'What actually happens during SSR', intro: - 'This page is server-rendered by Next.js. The left panel is frozen at the hydration first paint — exactly what the server sent. The right panel is what the hook knows right now.', + 'This page is HTML that Next.js built on the server. The left panel is exactly what the server sent. The right panel is what the hook knows right now.', serverPanel: 'First paint (what the server rendered)', serverNote: - 'Always desktop / unknown, on every device — that is why server HTML and client HTML can never disagree.', + 'Always desktop / unknown, whatever device you arrive on. Server and browser start from the same value, so they cannot disagree.', livePanel: 'Live values', liveNote: - 'Corrected in a single render right after hydration. isTouchPrimary and orientation keep updating live.', + 'The moment the browser takes over the page, one render fills in the real values. Touch and orientation keep following after that.', waitingBadge: 'server default', hydratedBadge: 'hydrated', - hint: 'Open this page on a phone, or reload with DevTools device emulation: the left panel stays desktop while the right one tells the truth — and the console logs zero hydration errors.', + hint: 'Open this page on a phone, or reload with DevTools device emulation. The left panel stays desktop while the right one tells the truth, and the console logs zero hydration errors.', }, install: { copyHint: 'Click to copy', @@ -77,19 +77,19 @@ export const en: LandingStrings = { usage: { overline: 'Usage', title: 'Three ways to use it', - body: 'From a one-line boolean to the framework-free engine — each import ships only what it actually needs.', + body: 'From a single boolean to running without React at all. You ship only what you import.', examples: [ { title: 'Read the full snapshot', - body: 'useDevice() returns type, os, boolean sugar, and the live fields. The server render and the hydration first paint always agree by construction, so you never write typeof window guards — branch on isHydrated only when you want to hide the one-render correction.', + body: 'One call gives you the device class, the OS, convenience booleans, and the values that change live. Server and browser always agree on the first paint, so you never write typeof window guards. Check isHydrated only when you want to hide the flash as the real values land.', }, { title: 'Import only what you ship', - body: 'The static hooks are listener-free and maximally tree-shakeable: importing only useIsMobile and useOS drops the reactive store entirely and ships ~1.1 kB. Perfect for OS-specific store buttons.', + body: 'Whatever you do not import is dropped at build time. Take only useIsMobile and useOS and the live-watching code disappears entirely, leaving ~1.1 kB. Perfect for OS-specific store buttons.', }, { title: 'Use the engine anywhere', - body: 'detectDevice() is the pure decision tree behind the hooks — no React, no globals. Inject a UA string (or Client Hints) and get the same deterministic verdict in middleware, on servers, or in tests.', + body: 'The function that does the actual detecting is exported on its own. No React, no browser globals. Hand it a UA string and you get the same answer in middleware, on a server, or in a test.', }, ], }, @@ -99,34 +99,34 @@ export const en: LandingStrings = { items: [ { title: 'Accurate where others fail', - body: 'iPads report as tablets even behind the macOS desktop UA (MacIntel + multitouch unmasking). Android tablets follow the official Mobile-token rule; Samsung DeX and in-app WebViews are handled.', + body: 'An iPad writing "Mac" into its user agent is still caught as a tablet, because maxTouchPoints is read alongside it. Android tablets follow the rule Google documents, and Samsung DeX and in-app webviews are handled too.', }, { title: 'Client Hints first', - body: 'Trusts navigator.userAgentData on Chromium — immune to user-agent freezing — and falls back to UA parsing everywhere else.', + body: 'On Chromium it reads Client Hints instead of the UA. Stripping the model name does not affect them. Only browsers without Client Hints fall back to parsing the UA string.', }, { title: 'SSR-safe by construction', - body: 'Server render and hydration first paint always match, so React 18/19 never log a hydration mismatch. The hook corrects itself in one post-hydration render.', + body: 'Server and browser use the same value on the first paint, so the two cannot disagree. That is why React 18/19 never log a hydration error here. The real values arrive one render later.', }, { title: 'Tiny and tree-shakeable', - body: 'Zero runtime dependencies, dual ESM/CJS. Importing only useIsMobile ships ~1.1 kB and drops the reactive store entirely — budgets are enforced in CI.', + body: 'No dependencies at all. Importing only useIsMobile ships ~1.1 kB, and the live-watching code never enters the bundle. CI checks these numbers.', }, { title: 'Hybrid reactivity', - body: 'type and os stay stable for the session, while isTouchPrimary and orientation update live via matchMedia — covering foldables, DeX docking, and iPad Stage Manager.', + body: 'Device class and OS stay pinned until the page reloads. Touch and orientation update live, so unfolding a foldable or attaching a keyboard to an iPad still gives the right answer.', }, { title: 'Proven in real browsers', - body: 'Beyond 76 unit tests, a Playwright matrix — iPhone 15, iPad Pro, Galaxy S24, Galaxy Tab S9, desktop Chrome and Safari — verifies detection and zero hydration errors.', + body: 'Beyond 85 unit tests, iPhone 15, iPad Pro, Galaxy S24, Galaxy Tab S9, and desktop Chrome and Safari are driven in real browsers to confirm the verdicts and zero errors.', }, ], }, compare: { overline: 'Comparison', title: 'What about react-device-detect?', - body: 'react-device-detect computes import-time constants from the UA, which crashes or mismatches under SSR, misreports iPads as desktops, never updates, and ships ~13 kB gzip that cannot be tree-shaken. It has been unmaintained since 2023, and its parser dependency moved to AGPL. react-device-check is a maintained MIT replacement designed around today’s platform realities.', + body: "react-device-detect computes its values the moment you import it and freezes them. That breaks under server rendering. It calls iPads desktops, never updates, and because unused parts cannot be dropped it always ships ~13 kB. It has been unmaintained since 2023, and its parser dependency moved to AGPL. react-device-check is a maintained MIT replacement designed around how platforms behave today.", }, api: { overline: 'API', @@ -134,15 +134,15 @@ export const en: LandingStrings = { rows: [ { name: 'useDevice()', - desc: 'Full snapshot: type, os, boolean sugar, live isTouchPrimary / orientation, and isHydrated.', + desc: 'Device class, OS, convenience booleans, and the live touch and orientation values in one call.', }, { name: 'useDeviceType()', - desc: "'mobile' | 'tablet' | 'desktop' — static per session, listener-free.", + desc: "'mobile' | 'tablet' | 'desktop'. Static per session, listener-free.", }, { name: 'useIsMobile() · useIsTablet() · useIsDesktop()', - desc: 'Boolean sugar — maximally tree-shakeable; importing only these drops the reactive store.', + desc: 'When one boolean is all you need. Importing only these drops the live-watching code from the bundle.', }, { name: 'useOS()', @@ -150,7 +150,7 @@ export const en: LandingStrings = { }, { name: 'detectDevice(input?, options?)', - desc: 'The pure engine — no React required, every signal injectable. Great for servers and tests.', + desc: 'The detection function without React. Pass the values in yourself, which suits servers and tests.', }, ], docsLead: 'Full API reference and known limitations live in the', diff --git a/website/content/ko.ts b/website/content/ko.ts index 9a7cfcf..6facb09 100644 --- a/website/content/ko.ts +++ b/website/content/ko.ts @@ -11,7 +11,7 @@ export const ko: LandingStrings = { titleAccent: '정확한 기기 판별', titlePost: '', tagline: - 'React CSR 앱에서도 Next.js SSR에서도, 사용자가 폰·태블릿·데스크톱 중 무엇으로, 어떤 OS에서 접근했는지 정확하게 알 수 있습니다. 의존성 0개에 iPad 위장 해제와 동결된 User-Agent까지 처리하며, hydration 에러는 단 한 건도 발생하지 않습니다.', + '사용자가 폰인지 태블릿인지 데스크톱인지, 어떤 OS를 쓰는지 알려줍니다. 자신을 Mac이라고 적어 보내는 iPad도 잡아내고, 서버에서 HTML을 미리 만드는 Next.js에서도 에러가 나지 않습니다. 의존성은 없습니다.', ctaDemo: '라이브로 보기', ctaGithub: 'GitHub', }, @@ -19,56 +19,56 @@ export const ko: LandingStrings = { overline: 'At a glance', title: '기기는 속여도, 답은 정직합니다', intro: - '각 화면은 그 기기에서 useDevice()가 반환하는 값입니다 — 자신을 속이는 기기까지 포함해서요.', + '브라우저가 보내는 User-Agent 문자열만 믿으면 틀리는 기기들을 모았습니다.', claimLabel: '기기의 주장', verdictLabel: '훅의 판별', devices: [ { name: 'iPhone 15', caption: - 'Safari의 “데스크톱 웹사이트 요청”을 켜면 iPhone조차 자신을 Mac이라고 주장합니다. 멀티터치와 화면 크기 교차검증이 그래도 mobile임을 밝혀냅니다.', + 'Safari의 “데스크톱 웹사이트 요청”을 켜면 iPhone조차 자신을 Mac이라고 주장합니다. 멀티터치와 화면 크기를 교차검증하면 그래도 mobile이 드러납니다.', }, { name: 'Galaxy S24', caption: - 'Chrome은 UA를 동결해 모든 안드로이드가 모델명 “K”로 보고됩니다. Client Hints가 정확히 판별합니다.', + 'Chrome이 UA에서 모델명을 지운 뒤로 모든 안드로이드가 “K”라고만 보고합니다. 대신 브라우저가 따로 제공하는 Client Hints를 읽어 판별합니다.', }, { name: 'iPad Pro', caption: - 'iPadOS 13부터 Mac으로 위장하지만, 멀티터치 언마스킹이 태블릿임을 밝혀냅니다.', + 'iPadOS 13부터 자신을 Mac이라고 적어 보냅니다. 그래도 터치 지점이 5개라 태블릿인 게 드러납니다.', }, { name: 'iMac', caption: - '위의 iPad와 완전히 같은 UA를 보내는 진짜 Mac입니다. 멀티터치 교차검증(maxTouchPoints: 0)이 둘을 구분합니다.', + '위의 iPad와 완전히 같은 UA를 보내는 진짜 Mac입니다. maxTouchPoints가 0이라는 사실 하나가 둘을 갈라놓습니다.', }, { name: 'Windows 터치 노트북', caption: - '터치스크린에 속지 않습니다 — 터치 신호는 Apple 위장 분기에서만 참조됩니다. desktop을 유지합니다.', + '터치스크린이 달렸어도 desktop입니다. maxTouchPoints는 Mac을 자처하는 기기를 가려낼 때만 보기 때문입니다.', }, { name: 'Android TV', caption: - 'Mobile 토큰이 없는 Android UA라 원래는 태블릿으로 분류될 신호지만, TV 마커를 먼저 확인합니다. 10-foot UI에는 desktop이 가장 맞는 답입니다.', + 'Mobile 토큰이 없는 Android UA라 원래는 태블릿으로 갈 조건이지만, TV 마커를 먼저 확인합니다. 리모컨으로 멀리서 보는 화면이라 desktop이 가장 가깝습니다.', }, ], }, demo: { overline: 'Live demo', - title: 'SSR 계약이 동작하는 모습', + title: '서버 렌더링에서 실제로 일어나는 일', intro: - '이 페이지는 Next.js로 서버 렌더됩니다. 왼쪽 패널은 hydration 첫 페인트 시점에 동결된 값 — 서버가 보낸 그대로입니다. 오른쪽 패널은 훅이 지금 알고 있는 값입니다.', + '이 페이지는 Next.js가 서버에서 미리 만들어 보낸 HTML입니다. 왼쪽은 서버가 보낸 그대로의 값이고, 오른쪽은 훅이 지금 알고 있는 값입니다.', serverPanel: '첫 페인트 (서버가 렌더한 값)', serverNote: - '어떤 기기에서든 항상 desktop / unknown — 그래서 서버 HTML과 클라이언트 HTML이 어긋날 수 없습니다.', + '어떤 기기로 접속하든 언제나 desktop / unknown입니다. 서버와 브라우저가 같은 값으로 시작하니 어긋날 일이 없습니다.', livePanel: '라이브 값', liveNote: - 'hydration 직후 단 한 번의 렌더로 교정됩니다. isTouchPrimary와 orientation은 계속 실시간 갱신됩니다.', + '브라우저가 화면을 이어받은 직후, 렌더 한 번으로 실제 값이 채워집니다. 터치 여부와 화면 방향은 그 뒤로도 계속 따라갑니다.', waitingBadge: 'server default', hydratedBadge: 'hydrated', - hint: '이 페이지를 폰에서 열거나 DevTools 기기 에뮬레이션으로 새로고침해 보세요. 왼쪽 패널은 desktop에 머물고 오른쪽이 진실을 말합니다 — 콘솔의 hydration 에러는 0건입니다.', + hint: '이 페이지를 폰에서 열거나 DevTools 기기 에뮬레이션으로 새로고침해 보세요. 왼쪽 패널은 desktop에 머물고 오른쪽이 진실을 말합니다. 콘솔의 hydration 에러는 0건입니다.', }, install: { copyHint: '클릭해서 복사', @@ -77,19 +77,19 @@ export const ko: LandingStrings = { usage: { overline: 'Usage', title: '세 가지 사용 방법', - body: '불리언 한 줄부터 프레임워크 없는 순수 엔진까지 — 각 import는 실제로 필요한 만큼만 번들에 담습니다.', + body: '불리언 하나만 쓰는 경우부터 React 없이 쓰는 경우까지. 가져다 쓴 만큼만 번들에 담깁니다.', examples: [ { title: '전체 스냅샷 읽기', - body: 'useDevice()는 type, os, 불리언 슈가와 실시간 필드를 반환합니다. 서버 렌더와 hydration 첫 페인트는 구조적으로 항상 일치하므로 typeof window 가드를 쓸 일이 없고, 교정 렌더 한 번을 감추고 싶을 때만 isHydrated로 분기하면 됩니다.', + body: 'useDevice() 하나로 기기 종류, OS, 편의용 불리언, 실시간으로 바뀌는 값까지 모두 받습니다. 서버와 브라우저의 첫 화면이 언제나 같아서 typeof window 같은 방어 코드를 쓸 일이 없습니다. 값이 채워지는 순간의 깜빡임을 감추고 싶을 때만 isHydrated를 보면 됩니다.', }, { title: '쓰는 것만 import', - body: '정적 훅은 리스너가 없고 최대한으로 tree-shaking됩니다. useIsMobile과 useOS만 import하면 반응형 스토어가 통째로 빠져 ~1.1 kB만 배송됩니다. OS별 앱스토어 버튼 같은 곳에 딱 맞습니다.', + body: '쓰지 않는 코드는 빌드할 때 번들에서 빠집니다. useIsMobile과 useOS만 가져오면 실시간 감시 코드가 통째로 빠져 ~1.1 kB만 나갑니다. OS별 앱스토어 버튼 같은 곳에 딱 맞습니다.', }, { title: 'React 밖에서도 사용', - body: 'detectDevice()는 훅 뒤에서 동작하는 순수 판별 트리입니다 — React도 전역 객체도 필요 없습니다. UA 문자열(또는 Client Hints)을 주입하면 미들웨어, 서버, 테스트 어디서든 같은 결정론적 결과를 얻습니다.', + body: '훅 안에서 실제 판별을 담당하는 함수를 그대로 꺼내 쓸 수 있습니다. React도, 브라우저 전역 객체도 필요 없습니다. UA 문자열만 넘기면 미들웨어와 서버, 테스트 어디서든 같은 답이 나옵니다.', }, ], }, @@ -99,34 +99,34 @@ export const ko: LandingStrings = { items: [ { title: '다른 라이브러리가 틀리는 곳에서 정확', - body: 'iPadOS 13+가 macOS 데스크톱 UA를 보내도 iPad를 태블릿으로 판별합니다(MacIntel + 멀티터치 언마스킹). 안드로이드 태블릿은 공식 Mobile 토큰 규칙으로 구분하고, Samsung DeX와 인앱 웹뷰도 처리합니다.', + body: 'iPad가 자신을 Mac이라고 적어 보내도, maxTouchPoints를 함께 보고 태블릿으로 잡아냅니다. 안드로이드 태블릿은 구글 공식 규칙으로 폰과 갈라내고, Samsung DeX와 카카오톡 같은 인앱 웹뷰도 처리합니다.', }, { title: 'Client Hints 우선', - body: 'Chromium에서는 navigator.userAgentData를 신뢰해 UA 동결에 면역이고, 그 외 환경에서는 UA 파싱으로 폴백합니다.', + body: 'Chrome 계열에서는 UA 대신 Client Hints를 먼저 읽습니다. 모델명이 지워져도 영향을 받지 않는 값입니다. 이걸 지원하지 않는 브라우저에서만 UA 문자열을 해석합니다.', }, { title: '구조적으로 SSR-safe', - body: '서버 렌더와 hydration 첫 페인트가 항상 일치해 React 18/19에서 hydration mismatch가 기록되지 않습니다. hydration 직후 렌더 한 번으로 실제 값으로 교정됩니다.', + body: '서버와 브라우저가 첫 화면에서 똑같은 값을 쓰기 때문에 둘이 어긋날 수가 없습니다. React 18/19에서 hydration 에러가 구조적으로 생기지 않는 이유입니다. 실제 값은 그 직후 렌더 한 번으로 채워집니다.', }, { - title: '작고 tree-shakeable', - body: '런타임 의존성 0개, ESM/CJS 듀얼. useIsMobile만 import하면 ~1.1 kB이고 반응형 스토어 전체가 번들에서 제거됩니다 — 예산은 CI에서 강제됩니다.', + title: '쓴 만큼만 번들에', + body: '의존성이 하나도 없습니다. useIsMobile만 가져오면 ~1.1 kB이고, 실시간 감시 코드는 번들에 아예 들어가지 않습니다. 이 크기는 CI에서 확인합니다.', }, { title: '하이브리드 반응성', - body: 'type과 os는 세션 동안 고정되고, isTouchPrimary와 orientation은 matchMedia로 실시간 갱신됩니다 — 폴더블, DeX 도킹, iPad Stage Manager까지 커버합니다.', + body: '기기 종류와 OS는 페이지를 새로 열기 전까지 고정입니다. 반면 터치 여부와 화면 방향은 실시간으로 따라가서, 폴더블을 펼치거나 iPad에 키보드를 붙여도 값이 맞습니다.', }, { title: '실브라우저 검증', - body: '76개 단위 테스트에 더해 Playwright 매트릭스 — iPhone 15, iPad Pro, Galaxy S24, Galaxy Tab S9, 데스크톱 Chrome/Safari — 가 판별 결과와 hydration 에러 0건을 검증합니다.', + body: '단위 테스트 85개에 더해, iPhone 15와 iPad Pro, Galaxy S24, Galaxy Tab S9, 데스크톱 Chrome/Safari를 실제 브라우저로 띄워 판별 결과와 에러 0건을 확인합니다.', }, ], }, compare: { overline: 'Comparison', title: 'react-device-detect는요?', - body: 'react-device-detect는 import 시점에 UA로 상수를 계산해 SSR에서 크래시하거나 mismatch가 나고, iPad를 데스크톱으로 오판하며, 값이 갱신되지 않고, tree-shaking이 불가능한 ~13 kB gzip을 항상 배송합니다. 2023년 이후 유지보수가 중단됐고 파서 의존성은 AGPL로 전환됐습니다. react-device-check는 오늘의 플랫폼 현실에 맞춰 설계된, 유지보수되는 MIT 대안입니다.', + body: 'react-device-detect는 import하는 순간 값을 계산해 고정합니다. 그래서 서버 렌더링 환경에서 깨집니다. iPad는 데스크톱으로 잘못 잡고, 한번 정해진 값은 바뀌지 않습니다. 쓰지 않는 부분을 덜어낼 수 없어 ~13 kB를 언제나 통째로 내려보냅니다. 2023년 이후 유지보수가 멈췄고 파서 의존성은 AGPL로 바뀌었습니다. react-device-check는 지금의 플랫폼 현실에 맞춰 새로 설계한 MIT 대안입니다.', }, api: { overline: 'API', @@ -134,15 +134,15 @@ export const ko: LandingStrings = { rows: [ { name: 'useDevice()', - desc: '전체 스냅샷: type, os, 불리언 슈가, 실시간 isTouchPrimary / orientation, isHydrated.', + desc: '기기 종류, OS, 편의용 불리언, 실시간으로 바뀌는 터치·화면 방향까지 한 번에.', }, { name: 'useDeviceType()', - desc: "'mobile' | 'tablet' | 'desktop' — 세션 동안 정적, 리스너 없음.", + desc: "'mobile' | 'tablet' | 'desktop'. 세션 동안 정적이고 리스너가 없습니다.", }, { name: 'useIsMobile() · useIsTablet() · useIsDesktop()', - desc: '불리언 슈가 — 최대 tree-shaking. 이것만 import하면 반응형 스토어가 번들에서 빠집니다.', + desc: '불리언 하나만 필요할 때. 이것만 가져오면 실시간 감시 코드가 번들에서 빠집니다.', }, { name: 'useOS()', @@ -150,7 +150,7 @@ export const ko: LandingStrings = { }, { name: 'detectDevice(input?, options?)', - desc: 'React 없이 쓰는 순수 엔진 — 모든 신호를 주입할 수 있어 서버와 테스트에 적합합니다.', + desc: 'React 없이 쓰는 판별 함수. 값을 직접 넘길 수 있어 서버와 테스트에 적합합니다.', }, ], docsLead: '전체 API 레퍼런스와 알려진 한계는 여기에 있습니다:',