Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ get into your bundle. Direct hook imports should be considered otherwise.
- [**`useNetworkState`**](./src/useNetworkState/index.ts) — Tracks the state of the browser's network connection.
- [**`useVibrate`**](./src/useVibrate/index.ts) — Provides vibration feedback using the Vibration API.
- [**`usePermission`**](./src/usePermission/index.ts) — Tracks the state of a permission.
- [**`useBattery`**](./src/useBattery/index.ts) — Tracks the state of the device's battery.

- #### Miscellaneous
- [**`useSyncedRef`**](./src/useSyncedRef/index.ts) — Like `useRef`, but it returns an immutable ref that contains the
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
144 changes: 144 additions & 0 deletions src/useBattery/index.dom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {act, renderHook} from '@ver0/react-hooks-testing';
import {beforeEach, describe, expect, it} from 'vitest';
import {useBattery} from '../index.js';
import type {BatteryManagerMock} from '../util/testing/setup/battery.test.js';
import {getBatteryMock, mockBattery, resetBatteryMock} from '../util/testing/setup/battery.test.js';
import {expectCallArgs, expectResultValue} from '../util/testing/test-helpers.js';

/** Flushes the microtask queue so the hook's pending getBattery() settles. */
const flushBattery = async () => {
await act(async () => {
await Promise.resolve();
});
};

describe('useBattery', () => {
beforeEach(() => {
resetBatteryMock();
});

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(Object.keys(value).toSorted()).toEqual([
'charging',
'chargingTime',
'dischargingTime',
'fetched',
'isSupported',
'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 flushBattery();

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 flushBattery();

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 the very handlers it registered on unmount', async () => {
const {unmount} = await renderHook(() => useBattery());

await flushBattery();

// Compared by reference, so removing a different function than the one
// registered -- a listener leak -- fails here.
const registered = [...mockBattery.addEventListener.mock.calls];
expect(registered).toHaveLength(4);

await unmount();

expect(mockBattery.removeEventListener.mock.calls).toEqual(registered);
});

it('should update state when battery events fire', async () => {
const {result} = await renderHook(() => useBattery());

await flushBattery();

let value = expectResultValue(result);
expect(value.level).toBe(0.75);

// Simulate battery level change
mockBattery.level = 0.5;

const [, levelChangeHandler] = expectCallArgs(mockBattery.addEventListener, 3);

await act(async () => {
levelChangeHandler();
});

value = expectResultValue(result);
expect(value.level).toBe(0.5);
});

it('should not subscribe when unmounted before getBattery() resolves', async () => {
let resolveBattery: (battery: BatteryManagerMock) => void = () => undefined;

getBatteryMock.mockImplementation(
async () =>
new Promise<BatteryManagerMock>((resolve) => {
resolveBattery = resolve;
}),
);

const {unmount} = await renderHook(() => useBattery());
await unmount();

resolveBattery(mockBattery);
await flushBattery();

expect(mockBattery.addEventListener).not.toHaveBeenCalled();
});

it('should report unfetched state when getBattery() rejects', async () => {
getBatteryMock.mockImplementation(async () => {
throw new Error('Battery API blocked by permissions policy');
});

const {result} = await renderHook(() => useBattery());

await flushBattery();

const value = expectResultValue(result);
expect(value.fetched).toBe(false);
expect(value.isSupported).toBe(true);
expect(value.charging).toBeUndefined();
expect(value.level).toBeUndefined();
expect(mockBattery.addEventListener).not.toHaveBeenCalled();
});
});
35 changes: 35 additions & 0 deletions src/useBattery/index.ssr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {renderHookServer as renderHook} from '@ver0/react-hooks-testing';
import {describe, expect, it} from 'vitest';
import {useBattery} from '../index.js';
import {expectResultValue} from '../util/testing/test-helpers.js';

describe('useBattery', () => {
it('should be defined', () => {
expect(useBattery).toBeDefined();
});

it('should render', async () => {
const {result} = await renderHook(() => useBattery());
expectResultValue(result);
});

it('should return isSupported as false in SSR', async () => {
const {result} = await renderHook(() => useBattery());
expect(expectResultValue(result).isSupported).toBe(false);
});

it('should return fetched as false in SSR', async () => {
const {result} = await renderHook(() => useBattery());
expect(expectResultValue(result).fetched).toBe(false);
});

it('should return undefined values in SSR', async () => {
const {result} = await renderHook(() => useBattery());
const value = expectResultValue(result);

expect(value.charging).toBeUndefined();
expect(value.chargingTime).toBeUndefined();
expect(value.dischargingTime).toBeUndefined();
expect(value.level).toBeUndefined();
});
});
156 changes: 156 additions & 0 deletions src/useBattery/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {useEffect, useState} from 'react';
import {isBrowser} from '../util/const.js';
import {off, on} from '../util/misc.js';

export type UseBatteryState = {
/**
* Whether the Battery Status API is supported by the browser.
*/
isSupported: boolean;
/**
* Whether the battery state has been fetched.
*/
fetched: boolean;
/**
* 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;
};

type BatteryManager = {
charging: boolean;
chargingTime: number;
dischargingTime: number;
level: number;
} & EventTarget;

type NavigatorWithBattery = Navigator & {
getBattery?: () => Promise<BatteryManager>;
};

const BATTERY_EVENTS = ['chargingchange', 'chargingtimechange', 'dischargingtimechange', 'levelchange'] as const;

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 <p>Battery API not supported</p>;
* }
*
* return (
* <p>
* Battery level: {level === undefined ? 'Unknown' : `${Math.round(level * 100)}%`}
* {charging && ' (Charging)'}
* </p>
* );
*/
export function useBattery(): UseBatteryState {
const [state, setState] = useState<UseBatteryState>(() => getBatteryState(null));

useEffect(() => {
// Not covered by the DOM suite: `nav` is resolved once at module scope (as in
// useNetworkState), so the mocked `getBattery` is always present by the time a
// test runs. The unsupported path is exercised by the SSR suite instead.
if (!nav?.getBattery) {
return undefined;
}

const {getBattery} = nav;

let battery: BatteryManager | null = null;
let mounted = true;

const handleChange = () => {
if (battery && mounted) {
setState(getBatteryState(battery));
}
};

const subscribe = async (): Promise<void> => {
try {
const current = await getBattery.call(nav);

// The effect may have been cleaned up while getBattery() was pending;
// subscribing then would leak listeners nothing will ever remove.
if (!mounted) {
return;
}

battery = current;
setState(getBatteryState(current));

for (const event of BATTERY_EVENTS) {
on(current, event, handleChange);
}
} catch (error: unknown) {
// Some browsers reject when the API is disabled by policy; report the
// state as unfetched rather than leaving the rejection unhandled.
// The warning itself stays uncovered: NODE_ENV is 'test' under vitest.
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.error('Failed to get battery status:', error);
}

if (mounted) {
setState(getBatteryState(null));
}
}
};

void subscribe();

return () => {
mounted = false;

if (battery) {
for (const event of BATTERY_EVENTS) {
off(battery, event, handleChange);
}
}
};
}, []);

return state;
}
Loading