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',