diff --git a/.gitignore b/.gitignore index 9a7fbfd..9faba2c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ playwright-report/ # Claude Code local-only artifacts .claude/agent-memory/ .claude/settings.local.json + +# TypeScript incremental build cache +*.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md index 5af5d7e..9d427d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,11 @@ pnpm vitest run -t "test name pattern" 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). +5. `src/core/media.ts`: the `listen`/`unlisten` matchMedia helpers (Safari < 14 `addListener` fallback) shared by every reactive store. +6. `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). +7. `src/core/dpr.ts`: the reactive store for `useDevicePixelRatio()`: one `(resolution: Xdppx)` listener whose query always describes the cached ratio, so a change event means the ratio moved. On a real change it re-arms on the new query; when the ratio is unchanged it returns before touching the listener (re-arming mid-dispatch would re-enter the handler). +8. `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. +9. `src/useDevice.ts` / `src/useDeviceType.ts` / `src/useOS.ts` / `src/useDevicePixelRatio.ts`: thin hook wrappers. Static hooks import only `core/static`, so importing them alone tree-shakes the reactive store away; `useDevicePixelRatio` imports only `core/dpr`, so it brings neither the detection engine nor the device listeners (both verified by the size-limit budgets). ### Invariants to preserve @@ -43,19 +45,21 @@ pnpm vitest run -t "test name pattern" - **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`. -- **`type`/`os` are static per session by contract**; only `isTouchPrimary`/`orientation` are reactive. +- **`type`/`os` are static per session by contract**; within `DeviceInfo` only `isTouchPrimary`/`orientation` are reactive. +- **The device pixel ratio stays out of `DeviceInfo`**: it lives in its own store so `useDevice()` keeps exactly two listeners and its budget stays flat. Adding a field there would charge every `useDevice()` caller for a listener they did not ask for. ### SSR contract -Server render and hydration first paint both return the frozen default (`desktop`/`unknown`, `isHydrated: false`) so server and client HTML always match; the hook corrects itself in one post-hydration render. The dist bundle carries a `'use client'` banner (added in `vite.config.ts`). +Server render and hydration first paint both return the frozen default (`desktop`/`unknown`, `isHydrated: false`) so server and client HTML always match; the hook corrects itself in one post-hydration render. `useDevicePixelRatio()` follows the same contract with a frozen default of `1`. The dist bundle carries a `'use client'` banner (added in `vite.config.ts`). ### 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/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. +- `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, and the unit-test counts in both READMEs plus `website/content/en.ts` and `ko.ts`. +- `src/test/helpers.ts` (`vi.stubGlobal` navigator stub, `stubDevicePixelRatio`, `dprQuery`) + `matchMediaMock.ts` (controllable harness) for store/hook tests; `setup.ts` resets the session caches and unstubs globals after each test. +- `matchMediaMock.ts` keys off the raw query string with no media-query semantics, so DPR tests must drive the exact generated query (`dprQuery(2)`) and re-read `listenerCount` on the new string after a change. - 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. The expected pixel ratio is read from the Playwright descriptor's `deviceScaleFactor` rather than duplicated in the spec. ### Adding a detection rule @@ -80,7 +84,7 @@ Write commit messages in English, subject and body, overriding the global Korean Vite library mode produces `dist/index.js` (CJS), `dist/index.mjs` (ESM), `dist/index.d.ts` (rolled-up declarations), and `dist/index.d.mts` (copied by the build script). Both JS bundles start with a `'use client'` banner. Dual-package resolution is verified with `pnpm dlx @arethetypeswrong/cli --pack .`. -React is the only external (peer dependency). Bundle budgets: everything ≤ 2 kB, `{ useIsMobile }` ≤ 1.15 kB, `{ useDevice }` ≤ 1.5 kB, `{ detectDevice }` ≤ 0.9 kB (min+brotli, enforced by `pnpm size`). If a budget changes, keep the size claims in both READMEs in sync. +React is the only external (peer dependency). Bundle budgets: everything ≤ 2 kB, `{ useIsMobile }` ≤ 1.15 kB, `{ useDevice }` ≤ 1.5 kB, `{ detectDevice }` ≤ 0.9 kB, `{ useDevicePixelRatio }` ≤ 0.7 kB (min+brotli, enforced by `pnpm size`). If a budget changes, keep the size claims in both READMEs, `website/content/{en,ko}.ts`, `website/content/code.ts`, and `website/lib/seo.ts` in sync. ### Examples diff --git a/README.ko.md b/README.ko.md index efe5cbf..9b34c5c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -8,7 +8,7 @@ **웹사이트 / 라이브 데모**: [react-device-check-site.vercel.app/ko](https://react-device-check-site.vercel.app/ko) -**사용자가 폰인지 태블릿인지 데스크톱인지, 어떤 OS를 쓰는지 알려주는 React 훅입니다.** 의존성이 없고 전부 가져다 써도 ~1.5 kB(min+brotli)입니다. React 17, 18, 19에서 동작하고 타입 정의를 함께 배포하며, Next.js처럼 서버에서 HTML을 미리 만드는 환경에서도 에러가 나지 않습니다. +**사용자가 폰인지 태블릿인지 데스크톱인지, 어떤 OS를 쓰는지 알려주는 React 훅입니다.** 의존성이 없고 전부 가져다 써도 ~1.6 kB(min+brotli)입니다. React 17, 18, 19에서 동작하고 타입 정의를 함께 배포하며, Next.js처럼 서버에서 HTML을 미리 만드는 환경에서도 에러가 나지 않습니다. ## 이런 문제를 풀어줍니다 @@ -26,7 +26,7 @@ const { type, os, isMobile, isTablet, isDesktop } = useDevice(); 꺼내 쓰는 건 이렇게 간단합니다. 어려운 쪽은 저 값을 정확하게 만드는 일이고, 아래 셋이 대표적인 경우입니다. **iPad 사용자에게 데스크톱 화면이 나갑니다.** -브라우저는 요청할 때마다 User-Agent(줄여서 UA) 문자열을 함께 보냅니다. 그런데 iPadOS 13부터 iPad는 이 문자열에 자신을 Mac이라고 적어 보냅니다. UA만 읽는 라이브러리는 여기에 그대로 속습니다. +브라우저는 요청할 때마다 User-Agent(줄여서 UA) 문자열을 함께 보냅니다. iPadOS 13부터 iPad는 이 문자열에 자신을 Mac이라고 적습니다. UA만 읽는 라이브러리는 그대로 속습니다. → UA와 함께 `maxTouchPoints`를 봅니다. 진짜 Mac은 0을 보고하고 iPad는 5를 보고하므로, "Mac인데 손가락 다섯 개가 닿는다"면 iPad입니다. @@ -36,7 +36,7 @@ Chrome이 UA에서 모델명을 지운 뒤로 모든 안드로이드 기기가 ` → Chrome 계열 브라우저는 UA 말고도 Client Hints라는 별도 정보를 제공합니다. 이쪽은 모델명 삭제와 무관하게 폰인지 아닌지를 알려줍니다. 이 값이 없는 브라우저에서는 UA에 `Mobile` 표시가 있는지로 갈라내는데, 구글이 안내하는 공식 방법입니다. **Next.js 콘솔에 hydration 에러가 쌓입니다.** -서버에서 HTML을 미리 만들 때는 접속자가 어떤 기기인지 알 수 없습니다. 반면 브라우저는 압니다. 그래서 서버가 보낸 HTML과 브라우저가 처음 그린 화면이 어긋나고, React가 이를 에러로 보고합니다. (hydration은 서버가 만들어 둔 HTML을 브라우저에서 React가 이어받는 과정입니다.) +서버에서 HTML을 미리 만들 때는 접속자가 어떤 기기인지 알 수 없습니다. 브라우저는 압니다. 이 비대칭 때문에 서버가 보낸 HTML과 브라우저가 처음 그린 화면이 어긋나고, React가 이를 에러로 보고합니다. (hydration은 서버가 만들어 둔 HTML을 브라우저에서 React가 이어받는 과정입니다.) → 첫 화면에서는 서버와 브라우저가 똑같이 `desktop` / `unknown`을 씁니다. 어긋날 값 자체가 없으니 에러도 없습니다. 진짜 기기 정보는 그 직후 렌더 한 번으로 채워집니다. @@ -44,7 +44,7 @@ Chrome이 UA에서 모델명을 지운 뒤로 모든 안드로이드 기기가 ` ## 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로 바뀌면서 현대화 길도 막혔습니다. +[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 대안입니다. @@ -126,6 +126,22 @@ useIsDesktop(): boolean 미디어 리스너를 하나도 붙이지 않습니다. 이 훅들만 import하면 반응형 스토어 전체가 번들에서 빠집니다. +### `useDevicePixelRatio(): number` + +CSS 픽셀 하나에 물리 픽셀이 몇 개 들어가는지 알려줍니다. `@2x`/`@3x` 에셋 선택, canvas 백킹 스토어 스케일, 지도·차트 타일 해상도 요청에 씁니다. + +```tsx +import { useDevicePixelRatio } from 'react-device-check'; + +function Hero() { + const dpr = useDevicePixelRatio(); + // width/height를 고정해 두면 소스만 바뀌므로 레이아웃이 흔들리지 않습니다. + return = 2 ? hero2x : hero1x} width={800} height={450} alt="" />; +} +``` + +반응형입니다. 브라우저 줌, 디스플레이 배율 변경, 밀도가 다른 화면으로 창을 옮길 때 값이 따라 움직입니다. 서버 렌더와 hydration 첫 페인트가 둘 다 `1`을 내므로 어긋날 값이 없고, 실제 비율은 그다음 렌더 한 번으로 채워집니다. 스토어가 `useDevice()`와 분리돼 있어서 이 훅만 import하면 0.6 kB이고, 판별 엔진도 기기 리스너도 딸려오지 않습니다. + ### `detectDevice(input?, options?)` (React 불필요) 훅 뒤에 있는 순수 엔진입니다. 모든 값을 주입할 수 있어 서버에서도 그대로 씁니다. @@ -160,7 +176,7 @@ const result = detectDevice({ ...getNavigatorInput(), screen: undefined }); ## SSR 동작 (Next.js) -서버는 기기를 알 수 없습니다. 그래서 이런 순서로 동작합니다. +서버는 기기를 알 수 없으니 이런 순서로 동작합니다. ``` ① 서버 렌더 → 고정된 기본값: { type: 'desktop', os: 'unknown', isHydrated: false } @@ -172,7 +188,8 @@ const result = detectDevice({ ...getNavigatorInput(), screen: undefined }); - 순수 CSR 앱(Vite, CRA)은 ①②를 건너뛰고 첫 렌더부터 정확한 값을 받습니다. - 첫 페인트에서 추측하면 안 되는 UI는 `isHydrated`를 보고 중립 플레이스홀더를 렌더하세요. -- **레이아웃은 CSS 미디어 쿼리로, 이 훅은 행동 분기용으로** 쓰는 편이 좋습니다. 어떤 SDK를 로드할지, 어떤 플로우를 시작할지, 어디로 리다이렉트할지 같은 것들입니다. 그러면 교정 렌더와 무관하게 CLS가 0으로 유지됩니다. +- 레이아웃은 CSS 미디어 쿼리로, 이 훅은 행동 분기용으로 쓰는 편이 좋습니다. 어떤 SDK를 로드할지, 어떤 플로우를 시작할지, 어디로 리다이렉트할지 같은 것들입니다. 교정 렌더가 기하를 건드리지 않으니 CLS는 0으로 유지됩니다. +- `useDevicePixelRatio()`도 같은 계약을 따릅니다. 서버와 첫 페인트에서 `1`, 실제 비율은 그다음 렌더 한 번으로 채워집니다. - 번들에 `'use client'` 배너가 들어 있어서, React Server Component에서 import하면 알 수 없는 훅 에러 대신 명확한 경계 에러가 납니다. ## 판별 원리 @@ -181,7 +198,7 @@ const result = detectDevice({ ...getNavigatorInput(), screen: undefined }); **1. User-Agent Client Hints** (`navigator.userAgentData`, Chrome 계열만 제공) -UA가 한 덩어리 문자열인 것과 달리, 이쪽은 "모바일인가", "어떤 OS인가"가 항목별로 따로 옵니다. Chrome이 UA에서 모델명을 지운 것과도 무관합니다. 그래서 이 값이 있으면 가장 먼저 믿습니다. +UA가 한 덩어리 문자열인 것과 달리, 이쪽은 "모바일인가", "어떤 OS인가"가 항목별로 따로 옵니다. Chrome이 UA에서 모델명을 지운 것과도 무관합니다. 이 값이 있으면 가장 먼저 믿는 이유입니다. 안드로이드에서 폰과 태블릿은 `mobile` 항목으로 갈립니다. 안드로이드인데 `mobile`이 `false`면 태블릿이라는 것이 구글이 안내하는 규칙입니다. @@ -191,7 +208,7 @@ Client Hints를 주지 않는 브라우저에서만 씁니다. 문자열에 `iPh **3. `maxTouchPoints` 교차검증** -Mac을 자처하는 iPad가 여기서 걸러집니다. 진짜 Mac은 동시에 인식하는 터치 지점이 0개인데 iPad는 5개입니다. 그래서 "Mac이라는데 터치 지점이 1개보다 많다"면 데스크톱 UA를 쓰는 Apple 터치 기기입니다. +Mac을 자처하는 iPad가 여기서 걸러집니다. 진짜 Mac은 동시에 인식하는 터치 지점이 0개인데 iPad는 5개입니다. "Mac이라는데 터치 지점이 1개보다 많다"면 데스크톱 UA를 쓰는 Apple 터치 기기라는 뜻입니다. 그게 iPad인지 데스크톱 모드를 켠 iPhone인지는 화면의 짧은 쪽 길이로 나눕니다. 가장 큰 iPhone이 440px 언저리, 가장 작은 iPad가 744px이라 두 범위가 겹치지 않습니다. @@ -216,10 +233,13 @@ Mac을 자처하는 iPad가 여기서 걸러집니다. 진짜 Mac은 동시에 - Chrome 안드로이드의 "데스크톱 사이트 요청"은 `desktop`/`linux`가 됩니다. 브라우저가 의도적으로 리눅스 데스크톱인 척하는 것이라 진짜와 구분할 방법이 없습니다. - 폴더블(갤럭시 폴드/플립)은 펼쳐도 접어도 `mobile`입니다. 지금 접혀 있는지 알려주는 값이 아예 없습니다. 펼침 상태에 맞춰야 하는 화면은 CSS 미디어 쿼리로 만드세요. - UA를 일부러 바꿔서 접속하는 것은 막지 못합니다. 받은 값에 일관된 답을 낼 뿐, 작정하고 속이는 상대를 가려내지는 못합니다. +- 서버는 화면 밀도도 알 수 없습니다. 언제나 `1`을 보내고 브라우저가 넘겨받은 뒤 바로잡습니다. 기기 종류와 달리 대신 읽을 헤더조차 없는데, UA 문자열에 밀도가 담기지 않기 때문입니다. Chromium은 Client Hints로 협상해 받을 수 있지만 opt-in이고 Chromium 전용입니다. +- 브라우저 줌과 진짜 고밀도 화면은 구분되지 않습니다. 둘 다 `devicePixelRatio`를 움직이고, 어느 쪽인지 가려낼 값이 없습니다. **아직 지원하지 않는 것** - HarmonyOS NEXT는 `os`가 `'unknown'`으로 나옵니다. `type`은 정확합니다. v1의 `os` 목록에 HarmonyOS를 아직 넣지 않았습니다. +- Safari 16 미만은 `resolution` 미디어 쿼리를 지원하지 않아 초기값은 맞지만 갱신되지 않습니다. iOS에서는 비율이 어차피 움직이지 않으니, 창이 디스플레이를 넘나드는 macOS Safari 15에서만 드러납니다. - React 17에서 서버 렌더링을 쓰면 hydration 경고가 찍힐 수 있습니다. React 17에는 이 훅이 쓰는 `useSyncExternalStore`가 없어서, 대신 넣어둔 코드가 첫 화면부터 브라우저 값을 그려버립니다. React 공식 대체 구현도 똑같은 한계를 갖고 있습니다. React 18/19에서는 생기지 않고, React 17이어도 서버 렌더링을 쓰지 않으면 문제없습니다. ## 로컬 개발 @@ -236,7 +256,7 @@ pnpm e2e # 두 예제에 대한 Playwright 기기 매트릭스 E2E ## 테스트 -- 단위 테스트 85개. 실제 UA 문자열 48개를 픽스처 매트릭스로 돌립니다(동결된 Chrome UA, iOS 26, iPad 데스크톱 모드, DeX, Firefox 태블릿, 카카오톡 웹뷰, Fire TV, Opera Mini, HarmonyOS NEXT 등). +- 단위 테스트 98개. 실제 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 예산을 실행합니다. @@ -250,4 +270,4 @@ pnpm e2e # 두 예제에 대한 Playwright 기기 매트릭스 E2E --- -**Keywords:** react 기기 판별 훅, react-device-detect 대안, 모바일 태블릿 데스크톱 판별 react, 아이패드 판별 react, useIsMobile 훅, SSR 안전 기기 판별, Next.js 기기 판별, user agent client hints react +**Keywords:** react 기기 판별 훅, react-device-detect 대안, 모바일 태블릿 데스크톱 판별 react, 아이패드 판별 react, useIsMobile 훅, SSR 안전 기기 판별, Next.js 기기 판별, user agent client hints react, 디바이스 픽셀 비율 훅, 레티나 판별 react diff --git a/README.md b/README.md index f3cd298..50b9ad1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **Website / live demo**: [react-device-check-site.vercel.app](https://react-device-check-site.vercel.app) -**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. +**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.6 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. ## What this solves @@ -126,6 +126,22 @@ useIsDesktop(): boolean These attach no media listeners at all. Import only these and the whole reactive store leaves the bundle. +### `useDevicePixelRatio(): number` + +The number of physical pixels per CSS pixel: pick a `@2x`/`@3x` asset, scale a canvas backing store, or request map and chart tiles at the right resolution. + +```tsx +import { useDevicePixelRatio } from 'react-device-check'; + +function Hero() { + const dpr = useDevicePixelRatio(); + // Fixed width/height, so swapping the source costs no layout shift. + return = 2 ? hero2x : hero1x} width={800} height={450} alt="" />; +} +``` + +Reactive: the ratio moves on browser zoom, on a display scale change, and when the window is dragged between screens of different densities. Server render and hydration first paint both report `1`, so there is nothing to mismatch, and the real ratio arrives one render later. The store is separate from `useDevice()`, so importing this hook alone costs 0.6 kB and brings neither the detection engine nor the device listeners. + ### `detectDevice(input?, options?)` (no React required) The pure engine behind the hooks. Every signal is injectable, so it runs on the server unchanged. @@ -173,6 +189,7 @@ Because the server and the browser use the same value for that first paint, the - 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. +- `useDevicePixelRatio()` follows the same contract: `1` on the server and the first paint, the real ratio one render later. - 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 @@ -216,10 +233,13 @@ These are the cases where `react-device-check` answers wrongly or cannot know at - 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. +- The server cannot know the pixel ratio either, so it always sends `1` and the browser corrects it after hydration. Unlike the device type there is no header to fall back on, because the UA string carries no display density. Chromium can negotiate one through Client Hints, but that is opt-in and Chromium only. +- Browser zoom is indistinguishable from a genuinely denser screen. Both move `devicePixelRatio` and nothing separates them. **Not supported yet** - HarmonyOS NEXT reports `os: 'unknown'`. The `type` is correct. HarmonyOS is simply not in the v1 `os` list yet. +- Safari before 16 has no `resolution` media query support, so the initial ratio is read correctly but never updates there. Ratios do not move on iOS anyway, so this only shows up on macOS Safari 15 when a window crosses displays. - 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 @@ -236,7 +256,7 @@ pnpm e2e # Playwright device-matrix E2E against both examples ## Testing -- 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). +- 98 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. @@ -250,4 +270,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, device pixel ratio hook, retina detection react diff --git a/e2e/device-detection.spec.ts b/e2e/device-detection.spec.ts index f418822..563e037 100644 --- a/e2e/device-detection.spec.ts +++ b/e2e/device-detection.spec.ts @@ -10,6 +10,11 @@ interface Expectation { touch?: boolean; } +// The ratio is whatever the emulated device reports, so it is read from the descriptor instead of being duplicated here. The matrix covers 1, 2, 2.5 and 3. +const expectedDpr = (testInfo: { + project: { use: { deviceScaleFactor?: number } }; +}) => String(testInfo.project.use.deviceScaleFactor ?? 1); + const EXPECTATIONS: Record = { iphone: { type: 'mobile', os: 'ios', touch: true }, ipad: { type: 'tablet', os: 'ios', touch: true }, @@ -35,6 +40,7 @@ test.describe('CSR example (Vite)', () => { String(expected.touch) ); } + await expect(page.getByTestId('dpr')).toHaveText(expectedDpr(testInfo)); // Pure CSR: hydration flag is true immediately (no server involved). await expect(page.getByTestId('isHydrated')).toHaveText('true'); }); @@ -50,6 +56,8 @@ test.describe('SSR example (Next.js)', () => { expect(html).toMatch(/data-testid="type"[^>]*>desktop]*>unknown]*>false]*>1 { await expect(page.getByTestId('os')).toHaveText(expected.os); } + await expect(page.getByTestId('dpr')).toHaveText(expectedDpr(testInfo)); + // The first-paint capture proves what the server rendered. await expect(page.getByTestId('first-type')).toHaveText('desktop'); + await expect(page.getByTestId('first-dpr')).toHaveText('1'); await expect(page.getByTestId('first-isHydrated')).toHaveText('false'); // React logs hydration mismatches via console.error, and there must be none. @@ -109,5 +120,7 @@ test.describe('reactivity contract', () => { // Device identity is session-static by contract, so rotation must not change it. await expect(page.getByTestId('type')).toHaveText(expected.type); + // The ratio tracks display density, not viewport size, so resizing must not move it either. + await expect(page.getByTestId('dpr')).toHaveText(expectedDpr(testInfo)); }); }); diff --git a/examples/basic/App.tsx b/examples/basic/App.tsx index 8c57133..df711f3 100644 --- a/examples/basic/App.tsx +++ b/examples/basic/App.tsx @@ -1,8 +1,9 @@ // Imports the library source directly for a fast edit-refresh loop. -import { useDevice } from '../../src'; +import { useDevice, useDevicePixelRatio } from '../../src'; export default function App() { const device = useDevice(); + const dpr = useDevicePixelRatio(); return (
@@ -49,9 +50,20 @@ export default function App() { +
+

useDevicePixelRatio()

+
+
+
dpr
+
{dpr}
+
+
+
+

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

); diff --git a/examples/nextjs/app/DeviceDemo.tsx b/examples/nextjs/app/DeviceDemo.tsx index 1123033..e1ab757 100644 --- a/examples/nextjs/app/DeviceDemo.tsx +++ b/examples/nextjs/app/DeviceDemo.tsx @@ -1,12 +1,15 @@ 'use client'; import { useState } from 'react'; -import { useDevice } from 'react-device-check'; +import { useDevice, useDevicePixelRatio } from 'react-device-check'; export function DeviceDemo() { const device = useDevice(); + const dpr = useDevicePixelRatio(); // Captured once during the hydration render, i.e. exactly what the server sent: the frozen desktop/unknown snapshot with isHydrated: false. const [firstPaint] = useState(device); + // Same capture for the ratio, which the server always reports as 1. + const [firstDpr] = useState(dpr); return ( <> @@ -47,6 +50,16 @@ export function DeviceDemo() { +
+

Live value from useDevicePixelRatio()

+
+
+
dpr
+
{dpr}
+
+
+
+

First paint (what the server rendered)

@@ -58,6 +71,10 @@ export function DeviceDemo() {
os
{firstPaint.os}
+
+
dpr
+
{firstDpr}
+
isHydrated
@@ -67,10 +84,10 @@ 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, and then the hook corrects - itself in one post-hydration render. No hydration error is ever - logged. + (desktop / unknown, and a ratio of 1). Because the hydration first + paint uses the same 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/package.json b/package.json index 8fba1d9..8643df1 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,12 @@ "import": "{ useDevice }", "limit": "1.5 kB" }, + { + "name": "useDevicePixelRatio only (no detection engine)", + "path": "dist/index.mjs", + "import": "{ useDevicePixelRatio }", + "limit": "0.7 kB" + }, { "name": "detectDevice only (no React)", "path": "dist/index.mjs", @@ -133,6 +139,9 @@ "responsive", "typescript", "zero-dependencies", - "tree-shakeable" + "tree-shakeable", + "device-pixel-ratio", + "dpr", + "retina" ] } diff --git a/src/core/dpr.ts b/src/core/dpr.ts new file mode 100644 index 0000000..9ce5b3a --- /dev/null +++ b/src/core/dpr.ts @@ -0,0 +1,71 @@ +import { isServer } from './env'; +import { listen, unlisten } from './media'; + +/** + * The ratio reported on the server and during the hydration first paint. 1 is the CSS pixel baseline: the only value that cannot disagree between server and client HTML. + */ +export const SERVER_DPR = 1; + +export const getServerDpr = (): number => SERVER_DPR; + +let snapshot: number | null = null; +const listeners = new Set<() => void>(); +let mql: MediaQueryList | null = null; + +// A few embedders report 0 or leave devicePixelRatio undefined; 1 is the CSS pixel baseline. +const read = (): number => window.devicePixelRatio || 1; + +/** + * Session-cached ratio. A number snapshot is referentially stable by nature, so unlike the device store this needs no cached-object guard against the useSyncExternalStore loop. + */ +export function getDpr(): number { + if (snapshot === null) snapshot = read(); + return snapshot; +} + +// A resolution query matches one exact ratio, so the query always describes the cached snapshot. That is what makes a change event mean "the ratio moved". +function attach(): void { + mql = window.matchMedia(`(resolution: ${getDpr()}dppx)`); + listen(mql, onChange); +} + +function detach(): void { + if (!mql) return; + unlisten(mql, onChange); + mql = null; +} + +function onChange(): void { + const next = read(); + // Bail before touching the listener when the ratio did not actually move: the current query still describes it, and re-arming mid-dispatch would re-enter this handler. + if (next === snapshot) return; + snapshot = next; + // The old query can never match again, so the listener moves onto the new ratio. + detach(); + attach(); + listeners.forEach((l) => l()); +} + +export function subscribe(listener: () => void): () => void { + const isFirst = listeners.size === 0; + // Registered before the re-sync below so a ratio that moved between render and subscription notifies the arriving subscriber too (the React 17 fallback reads the snapshot before subscribing). + listeners.add(listener); + if (!isServer && isFirst) { + // The listener attaches lazily with the first subscriber and detaches with the last one, so apps that never call the hook pay nothing. + getDpr(); + attach(); + // Re-sync: the ratio may have moved between the first read (render) and subscription (passive effect). + onChange(); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) detach(); + }; +} + +/** Test-only: clears the cached ratio and detaches any leaked listener. Not re-exported from the package entry. */ +export function resetDprForTesting(): void { + snapshot = null; + listeners.clear(); + detach(); +} diff --git a/src/core/media.ts b/src/core/media.ts new file mode 100644 index 0000000..6cee007 --- /dev/null +++ b/src/core/media.ts @@ -0,0 +1,14 @@ +/** + * matchMedia listener helpers shared by every reactive store. Kept in one place so each store does not repeat the legacy branch. + */ + +// addListener is the Safari < 14 path: MediaQueryList did not implement EventTarget there. +export function listen(mql: MediaQueryList, cb: () => void): void { + if (mql.addEventListener) mql.addEventListener('change', cb); + else mql.addListener(cb); +} + +export function unlisten(mql: MediaQueryList, cb: () => void): void { + if (mql.removeEventListener) mql.removeEventListener('change', cb); + else mql.removeListener(cb); +} diff --git a/src/core/store.ts b/src/core/store.ts index ea403dd..ac4e86b 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -1,5 +1,6 @@ import type { DeviceInfo } from '../types'; import { isServer } from './env'; +import { listen, unlisten } from './media'; import { getStaticInfo, SERVER_STATIC } from './static'; /** @@ -53,17 +54,6 @@ function onChange(): void { } } -// addListener is the Safari < 14 path. -function listen(mql: MediaQueryList, cb: () => void): void { - if (mql.addEventListener) mql.addEventListener('change', cb); - else mql.addListener(cb); -} - -function unlisten(mql: MediaQueryList, cb: () => void): void { - if (mql.removeEventListener) mql.removeEventListener('change', cb); - else mql.removeListener(cb); -} - export function subscribe(listener: () => void): () => void { const isFirst = listeners.size === 0; // The listener is registered BEFORE the lazy-attach re-sync below, so a re-sync that detects moved media state notifies the arriving subscriber too (the React 17 fallback reads the snapshot before subscribing and would otherwise stay stale until the next media change). diff --git a/src/index.ts b/src/index.ts index 6418918..86a1374 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export { useIsDesktop, } from './useDeviceType'; export { useOS } from './useOS'; +export { useDevicePixelRatio } from './useDevicePixelRatio'; export { detectDevice } from './core/detect'; export { getNavigatorInput } from './core/env'; export type { diff --git a/src/test/dpr.test.ts b/src/test/dpr.test.ts new file mode 100644 index 0000000..6601c17 --- /dev/null +++ b/src/test/dpr.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getDpr, getServerDpr, SERVER_DPR, subscribe } from '../core/dpr'; +import { dprQuery, stubDevicePixelRatio } from './helpers'; +import { installMatchMedia } from './matchMediaMock'; + +describe('device pixel ratio store', () => { + beforeEach(() => { + stubDevicePixelRatio(2); + }); + + it('should read the current ratio', () => { + installMatchMedia(); + expect(getDpr()).toBe(2); + }); + + it('should return a stable value across repeated calls', () => { + installMatchMedia(); + expect(getDpr()).toBe(getDpr()); + }); + + it('should fall back to 1 when the environment reports no usable ratio', () => { + stubDevicePixelRatio(0); + installMatchMedia(); + expect(getDpr()).toBe(1); + }); + + it('should attach a resolution listener on first subscribe and detach on last unsubscribe', () => { + const media = installMatchMedia(); + expect(media.listenerCount(dprQuery(2))).toBe(0); + + const unsubA = subscribe(() => {}); + const unsubB = subscribe(() => {}); + // One store-level listener regardless of how many subscribers there are. + expect(media.listenerCount(dprQuery(2))).toBe(1); + + unsubA(); + expect(media.listenerCount(dprQuery(2))).toBe(1); + unsubB(); + expect(media.listenerCount(dprQuery(2))).toBe(0); + }); + + it('should notify listeners and re-arm on the new query when the ratio changes', () => { + const media = installMatchMedia(); + const listener = vi.fn(); + expect(getDpr()).toBe(2); + const unsubscribe = subscribe(listener); + + stubDevicePixelRatio(3); + media.set(dprQuery(2), false); + + expect(getDpr()).toBe(3); + expect(listener).toHaveBeenCalledTimes(1); + // The old query can never match again, so the listener has to move to the new one. + expect(media.listenerCount(dprQuery(2))).toBe(0); + expect(media.listenerCount(dprQuery(3))).toBe(1); + + unsubscribe(); + expect(media.listenerCount(dprQuery(3))).toBe(0); + }); + + it('should not notify when a change event fires without an actual ratio change', () => { + const media = installMatchMedia(); + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + + media.set(dprQuery(2), false); + + expect(getDpr()).toBe(2); + expect(listener).not.toHaveBeenCalled(); + expect(media.listenerCount(dprQuery(2))).toBe(1); + unsubscribe(); + }); + + it('should notify the arriving subscriber when the ratio moved between render and subscription', () => { + // Regression guard for the React 17 fallback, which reads the snapshot before it subscribes. + installMatchMedia(); + expect(getDpr()).toBe(2); + + stubDevicePixelRatio(3); + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + + expect(listener).toHaveBeenCalledTimes(1); + expect(getDpr()).toBe(3); + unsubscribe(); + }); + + it('should expose a stable server ratio of 1', () => { + expect(SERVER_DPR).toBe(1); + expect(getServerDpr()).toBe(1); + expect(getServerDpr()).toBe(getServerDpr()); + }); +}); diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 27d9a0c..fb4e139 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -13,3 +13,14 @@ export function stubNavigatorFromFixture(fx: Partial): void { }); if (fx.screen) vi.stubGlobal('screen', fx.screen); } + +/** + * Sets window.devicePixelRatio. jsdom declares it [Replaceable], so vi.stubGlobal redefines it cleanly and vi.unstubAllGlobals() restores jsdom's getter. + * Stubbing alone fires no media change: pair it with the matchMedia controller to drive the DPR store. + */ +export function stubDevicePixelRatio(ratio: number): void { + vi.stubGlobal('devicePixelRatio', ratio); +} + +/** The exact query the DPR store builds for a given ratio. Tests must use it verbatim: the matchMedia mock keys off the raw string. */ +export const dprQuery = (ratio: number): string => `(resolution: ${ratio}dppx)`; diff --git a/src/test/hooks.test.tsx b/src/test/hooks.test.tsx index 9883c68..df459bb 100644 --- a/src/test/hooks.test.tsx +++ b/src/test/hooks.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useSyncExternalStoreFallback } from '../compat'; import { useDevice, + useDevicePixelRatio, useDeviceType, useIsDesktop, useIsMobile, @@ -12,7 +13,11 @@ import { } from '../index'; import { getServerSnapshot, getSnapshot, subscribe } from '../core/store'; import { installMatchMedia } from './matchMediaMock'; -import { stubNavigatorFromFixture } from './helpers'; +import { + dprQuery, + stubDevicePixelRatio, + stubNavigatorFromFixture, +} from './helpers'; const TOUCH_QUERY = '(pointer: coarse)'; const PORTRAIT_QUERY = '(orientation: portrait)'; @@ -35,6 +40,10 @@ function DeviceProbe() { return
{JSON.stringify(device)}
; } +function DprProbe() { + return
{useDevicePixelRatio()}
; +} + function StaticProbe() { const type = useDeviceType(); const os = useOS(); @@ -146,6 +155,53 @@ describe('hooks', () => { }); }); + describe('useDevicePixelRatio', () => { + it('should return the current ratio', () => { + stubDevicePixelRatio(3); + const { getByTestId } = render(); + expect(getByTestId('dpr').textContent).toBe('3'); + }); + + it('should re-render with the new ratio when the display density changes', () => { + stubDevicePixelRatio(2); + const media = installMatchMedia(); + const { getByTestId } = render(); + expect(getByTestId('dpr').textContent).toBe('2'); + + // A window dragged onto a 1x monitor: stub the new ratio, then fire the query that described the old one. + stubDevicePixelRatio(1); + act(() => { + media.set(dprQuery(2), false); + }); + expect(getByTestId('dpr').textContent).toBe('1'); + }); + + it('should not affect the device snapshot', () => { + stubDevicePixelRatio(3); + stubNavigatorFromFixture(IPHONE); + const { getByTestId } = render( + <> + + + + ); + expect(getByTestId('dpr').textContent).toBe('3'); + expect(readJSON(getByTestId('device'))).not.toHaveProperty('dpr'); + }); + + it('should not re-render in a loop (bounded render count)', () => { + stubDevicePixelRatio(2); + let renders = 0; + function CountingProbe() { + renders += 1; + useDevicePixelRatio(); + return null; + } + render(); + expect(renders).toBeLessThanOrEqual(2); + }); + }); + describe('React.StrictMode', () => { it('should survive double mounting with correct values', () => { stubNavigatorFromFixture(IPHONE); diff --git a/src/test/setup.ts b/src/test/setup.ts index 436ee13..2642b80 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,6 +1,7 @@ import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import { afterEach, vi } from 'vitest'; +import { resetDprForTesting } from '../core/dpr'; import { resetStaticInfoForTesting } from '../core/static'; import { resetStoreForTesting } from '../core/store'; @@ -8,6 +9,7 @@ import { resetStoreForTesting } from '../core/store'; afterEach(() => { cleanup(); resetStoreForTesting(); + resetDprForTesting(); resetStaticInfoForTesting(); vi.unstubAllGlobals(); }); diff --git a/src/test/ssr.test.tsx b/src/test/ssr.test.tsx index f31e521..c53c236 100644 --- a/src/test/ssr.test.tsx +++ b/src/test/ssr.test.tsx @@ -4,7 +4,17 @@ import { renderToString } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import { detectDevice } from '../core/detect'; import { getNavigatorInput } from '../core/env'; -import { useDevice, useDeviceType, useIsMobile, useOS } from '../index'; +import { + useDevice, + useDevicePixelRatio, + useDeviceType, + useIsMobile, + useOS, +} from '../index'; + +function DprProbe() { + return
{JSON.stringify({ dpr: useDevicePixelRatio() })}
; +} function DeviceProbe() { const device = useDevice(); @@ -63,6 +73,10 @@ describe('SSR (Node environment)', () => { expect(html).toContain('"orientation":"landscape"'); }); + it('should renderToString useDevicePixelRatio without touching window, with a ratio of 1', () => { + expect(decode(renderToString())).toContain('"dpr":1'); + }); + it('should renderToString static hooks without crashing, with server defaults', () => { const html = decode(renderToString()); expect(html).toContain('"type":"desktop"'); diff --git a/src/useDevicePixelRatio.ts b/src/useDevicePixelRatio.ts new file mode 100644 index 0000000..9ada867 --- /dev/null +++ b/src/useDevicePixelRatio.ts @@ -0,0 +1,13 @@ +import { useSES } from './compat'; +import { getDpr, getServerDpr, subscribe } from './core/dpr'; + +/** + * Returns `window.devicePixelRatio`, the number of physical pixels per CSS pixel. Use it to pick a `@2x`/`@3x` asset, scale a canvas backing store, or request map and chart tiles at the right resolution. + * + * Reactive: the value updates live when the ratio moves, which happens on browser zoom, on a display scale change, and when the window is dragged between screens of different densities. + * + * SSR contract: the server render and the hydration first paint both return `1`, so server and client HTML always match. The real ratio arrives in one post-hydration render. + */ +export function useDevicePixelRatio(): number { + return useSES(subscribe, getDpr, getServerDpr); +} diff --git a/website/content/en.ts b/website/content/en.ts index f15522e..8af187f 100644 --- a/website/content/en.ts +++ b/website/content/en.ts @@ -6,7 +6,7 @@ export const en: LandingStrings = { langHref: '/ko', }, hero: { - badges: ['~1.5 kB min+brotli', 'Zero dependencies', 'React 17–19', 'MIT'], + badges: ['~1.6 kB min+brotli', 'Zero dependencies', 'React 17–19', 'MIT'], titlePre: "Device detection that's right ", titleAccent: 'in CSR and SSR', titlePost: '', @@ -119,7 +119,7 @@ export const en: LandingStrings = { }, { title: 'Proven in real browsers', - 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.', + body: 'Beyond 98 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.', }, ], }, @@ -148,6 +148,10 @@ export const en: LandingStrings = { name: 'useOS()', desc: "'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown'.", }, + { + name: 'useDevicePixelRatio()', + desc: 'The physical pixels per CSS pixel, for @2x assets and canvas scaling. Reactive, and 1 until hydration.', + }, { name: 'detectDevice(input?, options?)', desc: 'The detection function without React. Pass the values in yourself, which suits servers and tests.', diff --git a/website/content/ko.ts b/website/content/ko.ts index 6facb09..6d102d6 100644 --- a/website/content/ko.ts +++ b/website/content/ko.ts @@ -6,7 +6,7 @@ export const ko: LandingStrings = { langHref: '/', }, hero: { - badges: ['~1.5 kB min+brotli', '의존성 0개', 'React 17–19', 'MIT'], + badges: ['~1.6 kB min+brotli', '의존성 0개', 'React 17–19', 'MIT'], titlePre: 'CSR에서도, SSR에서도 ', titleAccent: '정확한 기기 판별', titlePost: '', @@ -115,18 +115,18 @@ export const ko: LandingStrings = { }, { title: '하이브리드 반응성', - body: '기기 종류와 OS는 페이지를 새로 열기 전까지 고정입니다. 반면 터치 여부와 화면 방향은 실시간으로 따라가서, 폴더블을 펼치거나 iPad에 키보드를 붙여도 값이 맞습니다.', + body: '기기 종류와 OS는 페이지를 새로 열기 전까지 고정입니다. 터치 여부와 화면 방향은 실시간으로 따라갑니다. 폴더블을 펼치거나 iPad에 키보드를 붙여도 값이 맞는 이유입니다.', }, { title: '실브라우저 검증', - body: '단위 테스트 85개에 더해, iPhone 15와 iPad Pro, Galaxy S24, Galaxy Tab S9, 데스크톱 Chrome/Safari를 실제 브라우저로 띄워 판별 결과와 에러 0건을 확인합니다.', + body: '단위 테스트 98개에 더해, iPhone 15와 iPad Pro, Galaxy S24, Galaxy Tab S9, 데스크톱 Chrome/Safari를 실제 브라우저로 띄워 판별 결과와 에러 0건을 확인합니다.', }, ], }, compare: { overline: 'Comparison', title: 'react-device-detect는요?', - body: 'react-device-detect는 import하는 순간 값을 계산해 고정합니다. 그래서 서버 렌더링 환경에서 깨집니다. iPad는 데스크톱으로 잘못 잡고, 한번 정해진 값은 바뀌지 않습니다. 쓰지 않는 부분을 덜어낼 수 없어 ~13 kB를 언제나 통째로 내려보냅니다. 2023년 이후 유지보수가 멈췄고 파서 의존성은 AGPL로 바뀌었습니다. react-device-check는 지금의 플랫폼 현실에 맞춰 새로 설계한 MIT 대안입니다.', + body: 'react-device-detect는 import하는 순간 값을 계산해 고정합니다. 서버 렌더링 환경에서 깨지는 건 그 결과입니다. iPad는 데스크톱으로 잘못 잡고, 한번 정해진 값은 바뀌지 않습니다. 쓰지 않는 부분을 덜어낼 수 없어 ~13 kB를 언제나 통째로 내려보냅니다. 2023년 이후 유지보수가 멈췄고 파서 의존성은 AGPL로 바뀌었습니다. react-device-check는 지금의 플랫폼 현실에 맞춰 새로 설계한 MIT 대안입니다.', }, api: { overline: 'API', @@ -148,6 +148,10 @@ export const ko: LandingStrings = { name: 'useOS()', desc: "'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown'.", }, + { + name: 'useDevicePixelRatio()', + desc: 'CSS 픽셀당 물리 픽셀 수. @2x 에셋 선택과 canvas 스케일에 씁니다. 반응형이고, hydration 전까지는 1입니다.', + }, { name: 'detectDevice(input?, options?)', desc: 'React 없이 쓰는 판별 함수. 값을 직접 넘길 수 있어 서버와 테스트에 적합합니다.', diff --git a/website/lib/seo.ts b/website/lib/seo.ts index a7a0b49..7c3f322 100644 --- a/website/lib/seo.ts +++ b/website/lib/seo.ts @@ -12,8 +12,8 @@ const TITLES: Record = { }; const DESCRIPTIONS: Record = { - en: 'Detect mobile, tablet, or desktop and the OS in any React app with zero dependencies, ~1.5 kB, and no hydration errors in Next.js. iPad-as-Mac unmasking, Client Hints first, React 17–19.', - ko: '의존성 0개, ~1.5 kB, Next.js hydration 에러 없이 어떤 React 앱에서든 모바일·태블릿·데스크톱과 OS를 판별하세요. iPad 위장 해제, Client Hints 우선, React 17–19 지원.', + en: 'Detect mobile, tablet, or desktop and the OS in any React app with zero dependencies, ~1.6 kB, and no hydration errors in Next.js. iPad-as-Mac unmasking, Client Hints first, React 17–19.', + ko: '의존성 0개, ~1.6 kB, Next.js hydration 에러 없이 어떤 React 앱에서든 모바일·태블릿·데스크톱과 OS를 판별하세요. iPad 위장 해제, Client Hints 우선, React 17–19 지원.', }; const PATHS: Record = { en: '/', ko: '/ko' }; @@ -32,6 +32,7 @@ export const buildMetadata = (locale: Locale): Metadata => ({ 'is-tablet', 'user-agent', 'client-hints', + 'device-pixel-ratio', 'ssr', 'nextjs', 'hydration',