Skip to content

Commit 92f3cc3

Browse files
childrentimeclaude
andauthored
feat: add useWakeLock hook (#194)
Reactive Screen Wake Lock API that prevents the screen from dimming or locking. Supports auto re-acquisition on visibility change, forceRequest, and proper sentinel cleanup. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e094adb commit 92f3cc3

8 files changed

Lines changed: 675 additions & 1 deletion

File tree

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@reactuses/core",
3-
"version": "6.1.12",
3+
"version": "6.2.0",
44
"description": "Collection of 100+ essential React Hooks with TypeScript support, tree-shaking, and SSR compatibility. Sensors, browser APIs, state management, animations, and more.",
55
"license": "Unlicense",
66
"homepage": "https://www.reactuse.com/",

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import { useFetchEventSource } from './useFetchEventSource'
107107
import { useMap } from './useMap'
108108
import { useColorMode } from './useColorMode'
109109
import { useSpeechRecognition } from './useSpeechRecognition'
110+
import { useWakeLock } from './useWakeLock'
110111

111112
export {
112113
usePrevious,
@@ -223,6 +224,7 @@ export {
223224
useMap,
224225
useColorMode,
225226
useSpeechRecognition,
227+
useWakeLock,
226228
}
227229

228230
export * from './useActiveElement/interface'
@@ -329,3 +331,4 @@ export * from './useFetchEventSource/interface'
329331
export * from './useMap/interface'
330332
export * from './useColorMode/interface'
331333
export * from './useSpeechRecognition/interface'
334+
export * from './useWakeLock/interface'
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import { act, renderHook } from '@testing-library/react'
2+
import { useWakeLock } from '.'
3+
4+
// Mock WakeLockSentinel
5+
function createMockSentinel() {
6+
const listeners: Record<string, Function[]> = {}
7+
return {
8+
released: false,
9+
type: 'screen' as WakeLockType,
10+
addEventListener: jest.fn((event: string, handler: Function, options?: { once?: boolean }) => {
11+
if (!listeners[event]) {
12+
listeners[event] = []
13+
}
14+
listeners[event].push(handler)
15+
}),
16+
removeEventListener: jest.fn((event: string, handler: Function) => {
17+
if (listeners[event]) {
18+
listeners[event] = listeners[event].filter(h => h !== handler)
19+
}
20+
}),
21+
release: jest.fn(async function (this: any) {
22+
this.released = true
23+
const handlers = listeners.release?.slice() ?? []
24+
listeners.release = []
25+
handlers.forEach(fn => fn())
26+
}),
27+
onrelease: null,
28+
dispatchEvent: jest.fn(),
29+
} as unknown as WakeLockSentinel
30+
}
31+
32+
describe('useWakeLock', () => {
33+
let mockSentinel: WakeLockSentinel
34+
let originalNavigator: Navigator
35+
36+
beforeEach(() => {
37+
mockSentinel = createMockSentinel()
38+
originalNavigator = globalThis.navigator
39+
40+
Object.defineProperty(globalThis, 'navigator', {
41+
value: {
42+
...originalNavigator,
43+
wakeLock: {
44+
request: jest.fn(async () => mockSentinel),
45+
},
46+
},
47+
writable: true,
48+
configurable: true,
49+
})
50+
51+
Object.defineProperty(document, 'visibilityState', {
52+
value: 'visible',
53+
writable: true,
54+
configurable: true,
55+
})
56+
})
57+
58+
afterEach(() => {
59+
Object.defineProperty(globalThis, 'navigator', {
60+
value: originalNavigator,
61+
writable: true,
62+
configurable: true,
63+
})
64+
jest.restoreAllMocks()
65+
})
66+
67+
it('should detect support', () => {
68+
const { result } = renderHook(() => useWakeLock())
69+
70+
expect(result.current.isSupported).toBe(true)
71+
expect(result.current.isActive).toBe(false)
72+
})
73+
74+
it('should detect no support', () => {
75+
Object.defineProperty(globalThis, 'navigator', {
76+
value: { ...originalNavigator },
77+
writable: true,
78+
configurable: true,
79+
})
80+
81+
const { result } = renderHook(() => useWakeLock())
82+
83+
expect(result.current.isSupported).toBe(false)
84+
})
85+
86+
it('should request a wake lock', async () => {
87+
const onRequest = jest.fn()
88+
const { result } = renderHook(() => useWakeLock({ onRequest }))
89+
90+
await act(async () => {
91+
await result.current.request()
92+
})
93+
94+
expect(navigator.wakeLock.request).toHaveBeenCalledWith('screen')
95+
expect(result.current.isActive).toBe(true)
96+
expect(onRequest).toHaveBeenCalledTimes(1)
97+
})
98+
99+
it('should release a wake lock', async () => {
100+
const onRelease = jest.fn()
101+
const { result } = renderHook(() => useWakeLock({ onRelease }))
102+
103+
await act(async () => {
104+
await result.current.request()
105+
})
106+
107+
expect(result.current.isActive).toBe(true)
108+
109+
await act(async () => {
110+
await result.current.release()
111+
})
112+
113+
expect(mockSentinel.release).toHaveBeenCalled()
114+
expect(result.current.isActive).toBe(false)
115+
expect(onRelease).toHaveBeenCalledTimes(1)
116+
})
117+
118+
it('should handle request error', async () => {
119+
const error = new Error('Wake lock request failed')
120+
const onError = jest.fn()
121+
122+
;(navigator.wakeLock.request as jest.Mock).mockRejectedValueOnce(error)
123+
124+
const { result } = renderHook(() => useWakeLock({ onError }))
125+
126+
await act(async () => {
127+
await result.current.request()
128+
})
129+
130+
expect(result.current.isActive).toBe(false)
131+
expect(onError).toHaveBeenCalledWith(error)
132+
})
133+
134+
it('should not request when not supported', async () => {
135+
Object.defineProperty(globalThis, 'navigator', {
136+
value: { ...originalNavigator },
137+
writable: true,
138+
configurable: true,
139+
})
140+
141+
const { result } = renderHook(() => useWakeLock())
142+
143+
await act(async () => {
144+
await result.current.request()
145+
})
146+
147+
expect(result.current.isActive).toBe(false)
148+
})
149+
150+
it('should not release when not active', async () => {
151+
const { result } = renderHook(() => useWakeLock())
152+
153+
await act(async () => {
154+
await result.current.release()
155+
})
156+
157+
// Should not throw
158+
expect(result.current.isActive).toBe(false)
159+
})
160+
161+
it('should release wake lock on unmount', async () => {
162+
const { result, unmount } = renderHook(() => useWakeLock())
163+
164+
await act(async () => {
165+
await result.current.request()
166+
})
167+
168+
expect(result.current.isActive).toBe(true)
169+
170+
unmount()
171+
172+
expect(mockSentinel.release).toHaveBeenCalled()
173+
})
174+
175+
it('should re-acquire wake lock on visibility change after auto-release', async () => {
176+
const mockSentinel2 = createMockSentinel()
177+
const requestMock = navigator.wakeLock.request as jest.Mock
178+
requestMock
179+
.mockResolvedValueOnce(mockSentinel)
180+
.mockResolvedValueOnce(mockSentinel2)
181+
182+
const { result } = renderHook(() => useWakeLock())
183+
184+
await act(async () => {
185+
await result.current.request()
186+
})
187+
188+
expect(result.current.isActive).toBe(true)
189+
expect(requestMock).toHaveBeenCalledTimes(1)
190+
191+
// Simulate browser auto-releasing wake lock (e.g. page becomes hidden)
192+
await act(async () => {
193+
await (mockSentinel.release as jest.Mock)()
194+
})
195+
196+
expect(result.current.isActive).toBe(false)
197+
198+
// Simulate page becoming visible again
199+
await act(async () => {
200+
Object.defineProperty(document, 'visibilityState', {
201+
value: 'visible',
202+
writable: true,
203+
configurable: true,
204+
})
205+
document.dispatchEvent(new Event('visibilitychange'))
206+
})
207+
208+
// Should re-acquire
209+
expect(requestMock).toHaveBeenCalledTimes(2)
210+
})
211+
212+
it('should not re-acquire wake lock after explicit release', async () => {
213+
const { result } = renderHook(() => useWakeLock())
214+
215+
await act(async () => {
216+
await result.current.request()
217+
})
218+
219+
expect(result.current.isActive).toBe(true)
220+
221+
await act(async () => {
222+
await result.current.release()
223+
})
224+
225+
// Simulate page becoming visible
226+
await act(async () => {
227+
Object.defineProperty(document, 'visibilityState', {
228+
value: 'visible',
229+
writable: true,
230+
configurable: true,
231+
})
232+
document.dispatchEvent(new Event('visibilitychange'))
233+
})
234+
235+
// Should NOT re-acquire since user explicitly released
236+
expect(navigator.wakeLock.request).toHaveBeenCalledTimes(1)
237+
})
238+
239+
it('should not re-acquire wake lock when not active', async () => {
240+
renderHook(() => useWakeLock())
241+
242+
await act(async () => {
243+
Object.defineProperty(document, 'visibilityState', {
244+
value: 'visible',
245+
writable: true,
246+
configurable: true,
247+
})
248+
document.dispatchEvent(new Event('visibilitychange'))
249+
})
250+
251+
// Should not request since never requested
252+
expect(navigator.wakeLock.request).not.toHaveBeenCalled()
253+
})
254+
255+
it('should handle release error gracefully', async () => {
256+
const error = new Error('Release failed')
257+
const onError = jest.fn()
258+
259+
const { result } = renderHook(() => useWakeLock({ onError }))
260+
261+
await act(async () => {
262+
await result.current.request()
263+
})
264+
265+
;(mockSentinel.release as jest.Mock).mockRejectedValueOnce(error)
266+
267+
await act(async () => {
268+
await result.current.release()
269+
})
270+
271+
expect(onError).toHaveBeenCalledWith(error)
272+
})
273+
274+
it('should provide stable function references', () => {
275+
const { result, rerender } = renderHook(() => useWakeLock())
276+
277+
const initialRequest = result.current.request
278+
const initialRelease = result.current.release
279+
const initialForceRequest = result.current.forceRequest
280+
281+
rerender()
282+
283+
expect(result.current.request).toBe(initialRequest)
284+
expect(result.current.release).toBe(initialRelease)
285+
expect(result.current.forceRequest).toBe(initialForceRequest)
286+
})
287+
288+
it('should release old sentinel before acquiring new one via forceRequest', async () => {
289+
const mockSentinel2 = createMockSentinel()
290+
const requestMock = navigator.wakeLock.request as jest.Mock
291+
requestMock
292+
.mockResolvedValueOnce(mockSentinel)
293+
.mockResolvedValueOnce(mockSentinel2)
294+
295+
const { result } = renderHook(() => useWakeLock())
296+
297+
await act(async () => {
298+
await result.current.forceRequest()
299+
})
300+
301+
expect(result.current.isActive).toBe(true)
302+
303+
await act(async () => {
304+
await result.current.forceRequest()
305+
})
306+
307+
// Old sentinel should have been released
308+
expect(mockSentinel.release).toHaveBeenCalled()
309+
expect(requestMock).toHaveBeenCalledTimes(2)
310+
expect(result.current.isActive).toBe(true)
311+
})
312+
313+
it('should defer request when page is not visible', async () => {
314+
Object.defineProperty(document, 'visibilityState', {
315+
value: 'hidden',
316+
writable: true,
317+
configurable: true,
318+
})
319+
320+
const { result } = renderHook(() => useWakeLock())
321+
322+
await act(async () => {
323+
await result.current.request()
324+
})
325+
326+
// Should not request immediately since page is hidden
327+
expect(navigator.wakeLock.request).not.toHaveBeenCalled()
328+
expect(result.current.isActive).toBe(false)
329+
330+
// Simulate page becoming visible
331+
await act(async () => {
332+
Object.defineProperty(document, 'visibilityState', {
333+
value: 'visible',
334+
writable: true,
335+
configurable: true,
336+
})
337+
document.dispatchEvent(new Event('visibilitychange'))
338+
})
339+
340+
// Now should acquire
341+
expect(navigator.wakeLock.request).toHaveBeenCalledTimes(1)
342+
})
343+
})

0 commit comments

Comments
 (0)