Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ playwright-report/
# Claude Code local-only artifacts
.claude/agent-memory/
.claude/settings.local.json

# TypeScript incremental build cache
*.tsbuildinfo
22 changes: 13 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,29 +33,33 @@ 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

- **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`.
- **`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

Expand All @@ -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

Expand Down
Loading
Loading