Skip to content

Commit 90d7c5f

Browse files
feat: add useBattery hook
Port `useBattery` from react-use, exposing charging state, charge level and the charging/discharging time estimates from the Battery Status API. The hook is SSR-safe: access to `navigator.getBattery` is guarded by `isBrowser`, so a server render reports `isSupported: false` with undefined readings rather than throwing. Listeners are not attached when the effect is cleaned up while `getBattery()` is still pending, and a rejected `getBattery()` falls back to the unfetched state instead of leaving an unhandled rejection. Refs #33
1 parent 502394d commit 90d7c5f

7 files changed

Lines changed: 391 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ get into your bundle. Direct hook imports should be considered otherwise.
116116
- [**`useNetworkState`**](./src/useNetworkState/index.ts) — Tracks the state of the browser's network connection.
117117
- [**`useVibrate`**](./src/useVibrate/index.ts) — Provides vibration feedback using the Vibration API.
118118
- [**`usePermission`**](./src/usePermission/index.ts) — Tracks the state of a permission.
119+
- [**`useBattery`**](./src/useBattery/index.ts) — Tracks the state of the device's battery.
119120

120121
- #### Miscellaneous
121122
- [**`useSyncedRef`**](./src/useSyncedRef/index.ts) — Like `useRef`, but it returns an immutable ref that contains the

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export * from './useThrottledState/index.js';
4141
export * from './useValidator/index.js';
4242

4343
// Navigator
44+
export * from './useBattery/index.js';
4445
export * from './useNetworkState/index.js';
4546
export * from './usePermission/index.js';
4647
export * from './useVibrate/index.js';

src/useBattery/index.dom.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import {act, renderHook} from '@ver0/react-hooks-testing';
2+
import {beforeEach, describe, expect, it} from 'vitest';
3+
import {useBattery} from '../index.js';
4+
import type {BatteryManagerMock} from '../util/testing/setup/battery.test.js';
5+
import {getBatteryMock, mockBattery, resetBatteryMock} from '../util/testing/setup/battery.test.js';
6+
import {expectCallArgs, expectResultValue} from '../util/testing/test-helpers.js';
7+
8+
/** Flushes the microtask queue so the hook's pending getBattery() settles. */
9+
const flushBattery = async () => {
10+
await act(async () => {
11+
await Promise.resolve();
12+
});
13+
};
14+
15+
describe('useBattery', () => {
16+
beforeEach(() => {
17+
resetBatteryMock();
18+
});
19+
20+
it('should be defined', () => {
21+
expect(useBattery).toBeDefined();
22+
});
23+
24+
it('should render', async () => {
25+
const {result} = await renderHook(() => useBattery());
26+
expectResultValue(result);
27+
});
28+
29+
it('should return an object of certain structure', async () => {
30+
const {result} = await renderHook(() => useBattery());
31+
const value = expectResultValue(result);
32+
33+
expect(Object.keys(value).toSorted()).toEqual([
34+
'charging',
35+
'chargingTime',
36+
'dischargingTime',
37+
'fetched',
38+
'isSupported',
39+
'level',
40+
]);
41+
});
42+
43+
it('should return isSupported: true when API is available', async () => {
44+
const {result} = await renderHook(() => useBattery());
45+
const value = expectResultValue(result);
46+
expect(value.isSupported).toBe(true);
47+
});
48+
49+
it('should fetch battery state when API is supported', async () => {
50+
const {result} = await renderHook(() => useBattery());
51+
52+
await flushBattery();
53+
54+
const value = expectResultValue(result);
55+
expect(value.fetched).toBe(true);
56+
expect(value.charging).toBe(true);
57+
expect(value.chargingTime).toBe(3600);
58+
expect(value.dischargingTime).toBe(Infinity);
59+
expect(value.level).toBe(0.75);
60+
});
61+
62+
it('should subscribe to battery events', async () => {
63+
await renderHook(() => useBattery());
64+
65+
await flushBattery();
66+
67+
expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingchange', expect.any(Function));
68+
expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingtimechange', expect.any(Function));
69+
expect(mockBattery.addEventListener).toHaveBeenCalledWith('dischargingtimechange', expect.any(Function));
70+
expect(mockBattery.addEventListener).toHaveBeenCalledWith('levelchange', expect.any(Function));
71+
});
72+
73+
it('should unsubscribe the very handlers it registered on unmount', async () => {
74+
const {unmount} = await renderHook(() => useBattery());
75+
76+
await flushBattery();
77+
78+
// Compared by reference, so removing a different function than the one
79+
// registered -- a listener leak -- fails here.
80+
const registered = [...mockBattery.addEventListener.mock.calls];
81+
expect(registered).toHaveLength(4);
82+
83+
await unmount();
84+
85+
expect(mockBattery.removeEventListener.mock.calls).toEqual(registered);
86+
});
87+
88+
it('should update state when battery events fire', async () => {
89+
const {result} = await renderHook(() => useBattery());
90+
91+
await flushBattery();
92+
93+
let value = expectResultValue(result);
94+
expect(value.level).toBe(0.75);
95+
96+
// Simulate battery level change
97+
mockBattery.level = 0.5;
98+
99+
const [, levelChangeHandler] = expectCallArgs(mockBattery.addEventListener, 3);
100+
101+
await act(async () => {
102+
levelChangeHandler();
103+
});
104+
105+
value = expectResultValue(result);
106+
expect(value.level).toBe(0.5);
107+
});
108+
109+
it('should not subscribe when unmounted before getBattery() resolves', async () => {
110+
let resolveBattery: (battery: BatteryManagerMock) => void = () => undefined;
111+
112+
getBatteryMock.mockImplementation(
113+
async () =>
114+
new Promise<BatteryManagerMock>((resolve) => {
115+
resolveBattery = resolve;
116+
}),
117+
);
118+
119+
const {unmount} = await renderHook(() => useBattery());
120+
await unmount();
121+
122+
resolveBattery(mockBattery);
123+
await flushBattery();
124+
125+
expect(mockBattery.addEventListener).not.toHaveBeenCalled();
126+
});
127+
128+
it('should report unfetched state when getBattery() rejects', async () => {
129+
getBatteryMock.mockImplementation(async () => {
130+
throw new Error('Battery API blocked by permissions policy');
131+
});
132+
133+
const {result} = await renderHook(() => useBattery());
134+
135+
await flushBattery();
136+
137+
const value = expectResultValue(result);
138+
expect(value.fetched).toBe(false);
139+
expect(value.isSupported).toBe(true);
140+
expect(value.charging).toBeUndefined();
141+
expect(value.level).toBeUndefined();
142+
expect(mockBattery.addEventListener).not.toHaveBeenCalled();
143+
});
144+
});

src/useBattery/index.ssr.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {renderHookServer as renderHook} from '@ver0/react-hooks-testing';
2+
import {describe, expect, it} from 'vitest';
3+
import {useBattery} from '../index.js';
4+
import {expectResultValue} from '../util/testing/test-helpers.js';
5+
6+
describe('useBattery', () => {
7+
it('should be defined', () => {
8+
expect(useBattery).toBeDefined();
9+
});
10+
11+
it('should render', async () => {
12+
const {result} = await renderHook(() => useBattery());
13+
expectResultValue(result);
14+
});
15+
16+
it('should return isSupported as false in SSR', async () => {
17+
const {result} = await renderHook(() => useBattery());
18+
expect(expectResultValue(result).isSupported).toBe(false);
19+
});
20+
21+
it('should return fetched as false in SSR', async () => {
22+
const {result} = await renderHook(() => useBattery());
23+
expect(expectResultValue(result).fetched).toBe(false);
24+
});
25+
26+
it('should return undefined values in SSR', async () => {
27+
const {result} = await renderHook(() => useBattery());
28+
const value = expectResultValue(result);
29+
30+
expect(value.charging).toBeUndefined();
31+
expect(value.chargingTime).toBeUndefined();
32+
expect(value.dischargingTime).toBeUndefined();
33+
expect(value.level).toBeUndefined();
34+
});
35+
});

src/useBattery/index.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {useEffect, useState} from 'react';
2+
import {isBrowser} from '../util/const.js';
3+
import {off, on} from '../util/misc.js';
4+
5+
export type UseBatteryState = {
6+
/**
7+
* Whether the Battery Status API is supported by the browser.
8+
*/
9+
isSupported: boolean;
10+
/**
11+
* Whether the battery state has been fetched.
12+
*/
13+
fetched: boolean;
14+
/**
15+
* Whether the battery is currently being charged.
16+
*/
17+
charging: boolean | undefined;
18+
/**
19+
* Time in seconds until the battery is fully charged, or Infinity if not charging.
20+
*/
21+
chargingTime: number | undefined;
22+
/**
23+
* Time in seconds until the battery is fully discharged, or Infinity if charging.
24+
*/
25+
dischargingTime: number | undefined;
26+
/**
27+
* Battery charge level between 0 and 1.
28+
*/
29+
level: number | undefined;
30+
};
31+
32+
type BatteryManager = {
33+
charging: boolean;
34+
chargingTime: number;
35+
dischargingTime: number;
36+
level: number;
37+
} & EventTarget;
38+
39+
type NavigatorWithBattery = Navigator & {
40+
getBattery?: () => Promise<BatteryManager>;
41+
};
42+
43+
const BATTERY_EVENTS = ['chargingchange', 'chargingtimechange', 'dischargingtimechange', 'levelchange'] as const;
44+
45+
const nav = isBrowser ? (globalThis.navigator as NavigatorWithBattery) : undefined;
46+
const isSupported = Boolean(nav?.getBattery);
47+
48+
function getBatteryState(battery: BatteryManager | null): UseBatteryState {
49+
if (!battery) {
50+
return {
51+
isSupported,
52+
fetched: false,
53+
charging: undefined,
54+
chargingTime: undefined,
55+
dischargingTime: undefined,
56+
level: undefined,
57+
};
58+
}
59+
60+
return {
61+
isSupported,
62+
fetched: true,
63+
charging: battery.charging,
64+
chargingTime: battery.chargingTime,
65+
dischargingTime: battery.dischargingTime,
66+
level: battery.level,
67+
};
68+
}
69+
70+
/**
71+
* Tracks the state of device's battery.
72+
*
73+
* @returns An object containing the battery state and whether the API is supported.
74+
*
75+
* @example
76+
* const { isSupported, level, charging } = useBattery();
77+
*
78+
* if (!isSupported) {
79+
* return <p>Battery API not supported</p>;
80+
* }
81+
*
82+
* return (
83+
* <p>
84+
* Battery level: {level === undefined ? 'Unknown' : `${Math.round(level * 100)}%`}
85+
* {charging && ' (Charging)'}
86+
* </p>
87+
* );
88+
*/
89+
export function useBattery(): UseBatteryState {
90+
const [state, setState] = useState<UseBatteryState>(() => getBatteryState(null));
91+
92+
useEffect(() => {
93+
// Not covered by the DOM suite: `nav` is resolved once at module scope (as in
94+
// useNetworkState), so the mocked `getBattery` is always present by the time a
95+
// test runs. The unsupported path is exercised by the SSR suite instead.
96+
if (!nav?.getBattery) {
97+
return undefined;
98+
}
99+
100+
const {getBattery} = nav;
101+
102+
let battery: BatteryManager | null = null;
103+
let mounted = true;
104+
105+
const handleChange = () => {
106+
if (battery && mounted) {
107+
setState(getBatteryState(battery));
108+
}
109+
};
110+
111+
const subscribe = async (): Promise<void> => {
112+
try {
113+
const current = await getBattery.call(nav);
114+
115+
// The effect may have been cleaned up while getBattery() was pending;
116+
// subscribing then would leak listeners nothing will ever remove.
117+
if (!mounted) {
118+
return;
119+
}
120+
121+
battery = current;
122+
setState(getBatteryState(current));
123+
124+
for (const event of BATTERY_EVENTS) {
125+
on(current, event, handleChange);
126+
}
127+
} catch (error: unknown) {
128+
// Some browsers reject when the API is disabled by policy; report the
129+
// state as unfetched rather than leaving the rejection unhandled.
130+
// The warning itself stays uncovered: NODE_ENV is 'test' under vitest.
131+
if (process.env.NODE_ENV === 'development') {
132+
// eslint-disable-next-line no-console
133+
console.error('Failed to get battery status:', error);
134+
}
135+
136+
if (mounted) {
137+
setState(getBatteryState(null));
138+
}
139+
}
140+
};
141+
142+
void subscribe();
143+
144+
return () => {
145+
mounted = false;
146+
147+
if (battery) {
148+
for (const event of BATTERY_EVENTS) {
149+
off(battery, event, handleChange);
150+
}
151+
}
152+
};
153+
}, []);
154+
155+
return state;
156+
}

0 commit comments

Comments
 (0)