From 243087f9c2674c065cf6a6a829b5aec84f0669ee Mon Sep 17 00:00:00 2001 From: Samitha Widanage Date: Sun, 11 Jan 2026 22:38:16 +0800 Subject: [PATCH 1/2] feat: add useBattery hook Implements useBattery hook that tracks device battery state using the Battery Status API. - Returns isSupported, fetched, charging, chargingTime, dischargingTime, level - Subscribes to battery change events (chargingchange, chargingtimechange, dischargingtimechange, levelchange) - SSR-safe with proper cleanup on unmount - Includes DOM and SSR tests Closes #33 (partial - sensor hooks) --- src/index.ts | 1 + src/useBattery/index.dom.test.ts | 133 +++++++++++++++++++++++++ src/useBattery/index.ssr.test.ts | 32 ++++++ src/useBattery/index.ts | 128 ++++++++++++++++++++++++ src/util/testing/setup/battery.test.ts | 27 +++++ vitest.config.ts | 6 +- 6 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 src/useBattery/index.dom.test.ts create mode 100644 src/useBattery/index.ssr.test.ts create mode 100644 src/useBattery/index.ts create mode 100644 src/util/testing/setup/battery.test.ts diff --git a/src/index.ts b/src/index.ts index ffcac791..cc23a164 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ export * from './useThrottledState/index.js'; export * from './useValidator/index.js'; // Navigator +export * from './useBattery/index.js'; export * from './useNetworkState/index.js'; export * from './usePermission/index.js'; export * from './useVibrate/index.js'; diff --git a/src/useBattery/index.dom.test.ts b/src/useBattery/index.dom.test.ts new file mode 100644 index 00000000..9434d46a --- /dev/null +++ b/src/useBattery/index.dom.test.ts @@ -0,0 +1,133 @@ +import {act, renderHook} from '@ver0/react-hooks-testing'; +import type {vi} from 'vitest'; +import {describe, expect, it, beforeEach} from 'vitest'; +import {useBattery} from '../index.js'; +import {expectResultValue} from '../util/testing/test-helpers.js'; + +type MockFn = ReturnType; + +type BatteryManager = { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; + addEventListener: MockFn; + removeEventListener: MockFn; +}; + +describe('useBattery', () => { + // Use the global getBattery mock that's already set up in the test environment + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const getBatteryMock = globalThis.navigator.getBattery as MockFn; + + // Access the mock battery object from the getBattery mock + let mockBattery: BatteryManager; + + beforeEach(async () => { + getBatteryMock.mockClear(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + mockBattery = await getBatteryMock(); + mockBattery.addEventListener.mockClear(); + mockBattery.removeEventListener.mockClear(); + }); + + it('should be defined', () => { + expect(useBattery).toBeDefined(); + }); + + it('should render', async () => { + const {result} = await renderHook(() => useBattery()); + expectResultValue(result); + }); + + it('should return an object of certain structure', async () => { + const {result} = await renderHook(() => useBattery()); + const value = expectResultValue(result); + + expect(typeof value).toBe('object'); + expect(Object.keys(value)).toEqual([ + 'isSupported', + 'fetched', + 'charging', + 'chargingTime', + 'dischargingTime', + 'level', + ]); + }); + + it('should return isSupported: true when API is available', async () => { + const {result} = await renderHook(() => useBattery()); + const value = expectResultValue(result); + expect(value.isSupported).toBe(true); + }); + + it('should fetch battery state when API is supported', async () => { + const {result} = await renderHook(() => useBattery()); + + await act(async () => { + await Promise.resolve(); + }); + + const value = expectResultValue(result); + expect(value.fetched).toBe(true); + expect(value.charging).toBe(true); + expect(value.chargingTime).toBe(3600); + expect(value.dischargingTime).toBe(Infinity); + expect(value.level).toBe(0.75); + }); + + it('should subscribe to battery events', async () => { + await renderHook(() => useBattery()); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingchange', expect.any(Function)); + expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingtimechange', expect.any(Function)); + expect(mockBattery.addEventListener).toHaveBeenCalledWith('dischargingtimechange', expect.any(Function)); + expect(mockBattery.addEventListener).toHaveBeenCalledWith('levelchange', expect.any(Function)); + }); + + it('should unsubscribe from battery events on unmount', async () => { + const {unmount} = await renderHook(() => useBattery()); + + await act(async () => { + await Promise.resolve(); + }); + + await unmount(); + + expect(mockBattery.removeEventListener).toHaveBeenCalledWith('chargingchange', expect.any(Function)); + expect(mockBattery.removeEventListener).toHaveBeenCalledWith('chargingtimechange', expect.any(Function)); + expect(mockBattery.removeEventListener).toHaveBeenCalledWith('dischargingtimechange', expect.any(Function)); + expect(mockBattery.removeEventListener).toHaveBeenCalledWith('levelchange', expect.any(Function)); + }); + + it('should update state when battery events fire', async () => { + const {result} = await renderHook(() => useBattery()); + + await act(async () => { + await Promise.resolve(); + }); + + let value = expectResultValue(result); + expect(value.level).toBe(0.75); + + // Simulate battery level change + mockBattery.level = 0.5; + + // Get the handler that was registered for levelchange + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const levelChangeHandler = mockBattery.addEventListener.mock.calls.find( + (call) => call[0] === 'levelchange', + )?.[1] as () => void; + + await act(async () => { + levelChangeHandler(); + }); + + value = expectResultValue(result); + expect(value.level).toBe(0.5); + }); +}); diff --git a/src/useBattery/index.ssr.test.ts b/src/useBattery/index.ssr.test.ts new file mode 100644 index 00000000..be61e7c2 --- /dev/null +++ b/src/useBattery/index.ssr.test.ts @@ -0,0 +1,32 @@ +import {renderHookServer as renderHook} from '@ver0/react-hooks-testing'; +import {describe, expect, it} from 'vitest'; +import {useBattery} from '../index.js'; + +describe('useBattery', () => { + it('should be defined', () => { + expect(useBattery).toBeDefined(); + }); + + it('should render', async () => { + const {result} = await renderHook(() => useBattery()); + expect(result.error).toBeUndefined(); + }); + + it('should return isSupported as false in SSR', async () => { + const {result} = await renderHook(() => useBattery()); + expect(result.value?.isSupported).toBe(false); + }); + + it('should return fetched as false in SSR', async () => { + const {result} = await renderHook(() => useBattery()); + expect(result.value?.fetched).toBe(false); + }); + + it('should return undefined values in SSR', async () => { + const {result} = await renderHook(() => useBattery()); + expect(result.value?.charging).toBeUndefined(); + expect(result.value?.chargingTime).toBeUndefined(); + expect(result.value?.dischargingTime).toBeUndefined(); + expect(result.value?.level).toBeUndefined(); + }); +}); diff --git a/src/useBattery/index.ts b/src/useBattery/index.ts new file mode 100644 index 00000000..f2feff95 --- /dev/null +++ b/src/useBattery/index.ts @@ -0,0 +1,128 @@ +import {useEffect, useState} from 'react'; +import {isBrowser} from '../util/const.js'; +import {off, on} from '../util/misc.js'; + +export type BatteryState = { + /** + * Whether the battery is currently being charged. + */ + charging: boolean | undefined; + /** + * Time in seconds until the battery is fully charged, or Infinity if not charging. + */ + chargingTime: number | undefined; + /** + * Time in seconds until the battery is fully discharged, or Infinity if charging. + */ + dischargingTime: number | undefined; + /** + * Battery charge level between 0 and 1. + */ + level: number | undefined; +}; + +export type UseBatteryState = BatteryState & { + /** + * Whether the Battery Status API is supported by the browser. + */ + isSupported: boolean; + /** + * Whether the battery state is currently being fetched. + */ + fetched: boolean; +}; + +type BatteryManager = { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; +} & EventTarget; + +type NavigatorWithBattery = Navigator & { + getBattery?: () => Promise; +}; + +const nav = isBrowser ? (globalThis.navigator as NavigatorWithBattery) : undefined; +const isSupported = Boolean(nav?.getBattery); + +function getBatteryState(battery: BatteryManager | null): UseBatteryState { + if (!battery) { + return { + isSupported, + fetched: false, + charging: undefined, + chargingTime: undefined, + dischargingTime: undefined, + level: undefined, + }; + } + + return { + isSupported, + fetched: true, + charging: battery.charging, + chargingTime: battery.chargingTime, + dischargingTime: battery.dischargingTime, + level: battery.level, + }; +} + +/** + * Tracks the state of device's battery. + * + * @returns An object containing the battery state and whether the API is supported. + * + * @example + * const { isSupported, level, charging } = useBattery(); + * + * if (!isSupported) { + * return

Battery API not supported

; + * } + * + * return ( + *

+ * Battery level: {level ? `${Math.round(level * 100)}%` : 'Unknown'} + * {charging && ' (Charging)'} + *

+ * ); + */ +export function useBattery(): UseBatteryState { + const [state, setState] = useState(() => getBatteryState(null)); + + useEffect(() => { + if (!isSupported || !nav?.getBattery) { + return; + } + + let battery: BatteryManager | null = null; + + const handleChange = () => { + if (battery) { + setState(getBatteryState(battery)); + } + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises,promise/catch-or-return,promise/prefer-await-to-then,promise/always-return + nav.getBattery().then((b) => { + battery = b; + setState(getBatteryState(battery)); + + on(battery, 'chargingchange', handleChange); + on(battery, 'chargingtimechange', handleChange); + on(battery, 'dischargingtimechange', handleChange); + on(battery, 'levelchange', handleChange); + }); + + return () => { + if (battery) { + off(battery, 'chargingchange', handleChange); + off(battery, 'chargingtimechange', handleChange); + off(battery, 'dischargingtimechange', handleChange); + off(battery, 'levelchange', handleChange); + } + }; + }, []); + + return state; +} diff --git a/src/util/testing/setup/battery.test.ts b/src/util/testing/setup/battery.test.ts new file mode 100644 index 00000000..bc19c032 --- /dev/null +++ b/src/util/testing/setup/battery.test.ts @@ -0,0 +1,27 @@ +import {vi} from 'vitest'; + +type BatteryManager = { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; + addEventListener: ReturnType; + removeEventListener: ReturnType; +}; + +const mockBattery: BatteryManager = { + charging: true, + chargingTime: 3600, + dischargingTime: Infinity, + level: 0.75, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), +}; + +const getBatteryMock = vi.fn<() => Promise>(async () => mockBattery); + +Object.defineProperty(globalThis.navigator, 'getBattery', { + value: getBatteryMock, + writable: true, + configurable: true, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 913b44a3..048c8743 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,11 @@ import {defineConfig} from 'vitest/config'; export default defineConfig({ test: { dir: './src', - setupFiles: ['./src/util/testing/setup/react-hooks.test.ts', './src/util/testing/setup/vibrate.test.ts'], + setupFiles: [ + './src/util/testing/setup/react-hooks.test.ts', + './src/util/testing/setup/vibrate.test.ts', + './src/util/testing/setup/battery.test.ts', + ], passWithNoTests: true, projects: [ { From 11be20c91cb3b9c11ab3e6fbc60a5c2e9a14a8d9 Mon Sep 17 00:00:00 2001 From: Samitha Widanage Date: Mon, 12 Jan 2026 09:53:16 +0800 Subject: [PATCH 2/2] fix(useBattery): handle unmount cleanup and promise rejection - Add mounted flag to prevent state updates after unmount - Prevent event listener registration if component unmounts before getBattery() resolves - Add .catch() handler to gracefully handle promise rejections - Fix misleading comment for fetched field --- src/useBattery/index.ts | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/useBattery/index.ts b/src/useBattery/index.ts index f2feff95..bbcfb43c 100644 --- a/src/useBattery/index.ts +++ b/src/useBattery/index.ts @@ -27,7 +27,7 @@ export type UseBatteryState = BatteryState & { */ isSupported: boolean; /** - * Whether the battery state is currently being fetched. + * Whether the battery state has been fetched. */ fetched: boolean; }; @@ -96,25 +96,43 @@ export function useBattery(): UseBatteryState { } let battery: BatteryManager | null = null; + let mounted = true; const handleChange = () => { - if (battery) { + if (battery && mounted) { setState(getBatteryState(battery)); } }; - // eslint-disable-next-line @typescript-eslint/no-floating-promises,promise/catch-or-return,promise/prefer-await-to-then,promise/always-return - nav.getBattery().then((b) => { - battery = b; - setState(getBatteryState(battery)); + // eslint-disable-next-line @typescript-eslint/no-floating-promises,promise/prefer-await-to-then,promise/always-return + nav + .getBattery() + .then((b) => { + if (!mounted) { + return; + } + + battery = b; + setState(getBatteryState(battery)); - on(battery, 'chargingchange', handleChange); - on(battery, 'chargingtimechange', handleChange); - on(battery, 'dischargingtimechange', handleChange); - on(battery, 'levelchange', handleChange); - }); + on(battery, 'chargingchange', handleChange); + on(battery, 'chargingtimechange', handleChange); + on(battery, 'dischargingtimechange', handleChange); + on(battery, 'levelchange', handleChange); + }) + .catch((error) => { + // Gracefully handle getBattery() rejections to avoid unhandled promise rejections + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error('Failed to get battery status:', error); + } + if (mounted) { + setState(getBatteryState(null)); + } + }); return () => { + mounted = false; if (battery) { off(battery, 'chargingchange', handleChange); off(battery, 'chargingtimechange', handleChange);