diff --git a/packages/core/package.json b/packages/core/package.json index 7676a842..372b6baf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@reactuses/core", - "version": "6.1.12", + "version": "6.2.0", "description": "Collection of 100+ essential React Hooks with TypeScript support, tree-shaking, and SSR compatibility. Sensors, browser APIs, state management, animations, and more.", "license": "Unlicense", "homepage": "https://www.reactuse.com/", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1da9589d..60813181 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -107,6 +107,7 @@ import { useFetchEventSource } from './useFetchEventSource' import { useMap } from './useMap' import { useColorMode } from './useColorMode' import { useSpeechRecognition } from './useSpeechRecognition' +import { useWakeLock } from './useWakeLock' export { usePrevious, @@ -223,6 +224,7 @@ export { useMap, useColorMode, useSpeechRecognition, + useWakeLock, } export * from './useActiveElement/interface' @@ -329,3 +331,4 @@ export * from './useFetchEventSource/interface' export * from './useMap/interface' export * from './useColorMode/interface' export * from './useSpeechRecognition/interface' +export * from './useWakeLock/interface' diff --git a/packages/core/src/useWakeLock/index.spec.ts b/packages/core/src/useWakeLock/index.spec.ts new file mode 100644 index 00000000..641e6bc9 --- /dev/null +++ b/packages/core/src/useWakeLock/index.spec.ts @@ -0,0 +1,343 @@ +import { act, renderHook } from '@testing-library/react' +import { useWakeLock } from '.' + +// Mock WakeLockSentinel +function createMockSentinel() { + const listeners: Record = {} + return { + released: false, + type: 'screen' as WakeLockType, + addEventListener: jest.fn((event: string, handler: Function, options?: { once?: boolean }) => { + if (!listeners[event]) { + listeners[event] = [] + } + listeners[event].push(handler) + }), + removeEventListener: jest.fn((event: string, handler: Function) => { + if (listeners[event]) { + listeners[event] = listeners[event].filter(h => h !== handler) + } + }), + release: jest.fn(async function (this: any) { + this.released = true + const handlers = listeners.release?.slice() ?? [] + listeners.release = [] + handlers.forEach(fn => fn()) + }), + onrelease: null, + dispatchEvent: jest.fn(), + } as unknown as WakeLockSentinel +} + +describe('useWakeLock', () => { + let mockSentinel: WakeLockSentinel + let originalNavigator: Navigator + + beforeEach(() => { + mockSentinel = createMockSentinel() + originalNavigator = globalThis.navigator + + Object.defineProperty(globalThis, 'navigator', { + value: { + ...originalNavigator, + wakeLock: { + request: jest.fn(async () => mockSentinel), + }, + }, + writable: true, + configurable: true, + }) + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) + }) + + afterEach(() => { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }) + jest.restoreAllMocks() + }) + + it('should detect support', () => { + const { result } = renderHook(() => useWakeLock()) + + expect(result.current.isSupported).toBe(true) + expect(result.current.isActive).toBe(false) + }) + + it('should detect no support', () => { + Object.defineProperty(globalThis, 'navigator', { + value: { ...originalNavigator }, + writable: true, + configurable: true, + }) + + const { result } = renderHook(() => useWakeLock()) + + expect(result.current.isSupported).toBe(false) + }) + + it('should request a wake lock', async () => { + const onRequest = jest.fn() + const { result } = renderHook(() => useWakeLock({ onRequest })) + + await act(async () => { + await result.current.request() + }) + + expect(navigator.wakeLock.request).toHaveBeenCalledWith('screen') + expect(result.current.isActive).toBe(true) + expect(onRequest).toHaveBeenCalledTimes(1) + }) + + it('should release a wake lock', async () => { + const onRelease = jest.fn() + const { result } = renderHook(() => useWakeLock({ onRelease })) + + await act(async () => { + await result.current.request() + }) + + expect(result.current.isActive).toBe(true) + + await act(async () => { + await result.current.release() + }) + + expect(mockSentinel.release).toHaveBeenCalled() + expect(result.current.isActive).toBe(false) + expect(onRelease).toHaveBeenCalledTimes(1) + }) + + it('should handle request error', async () => { + const error = new Error('Wake lock request failed') + const onError = jest.fn() + + ;(navigator.wakeLock.request as jest.Mock).mockRejectedValueOnce(error) + + const { result } = renderHook(() => useWakeLock({ onError })) + + await act(async () => { + await result.current.request() + }) + + expect(result.current.isActive).toBe(false) + expect(onError).toHaveBeenCalledWith(error) + }) + + it('should not request when not supported', async () => { + Object.defineProperty(globalThis, 'navigator', { + value: { ...originalNavigator }, + writable: true, + configurable: true, + }) + + const { result } = renderHook(() => useWakeLock()) + + await act(async () => { + await result.current.request() + }) + + expect(result.current.isActive).toBe(false) + }) + + it('should not release when not active', async () => { + const { result } = renderHook(() => useWakeLock()) + + await act(async () => { + await result.current.release() + }) + + // Should not throw + expect(result.current.isActive).toBe(false) + }) + + it('should release wake lock on unmount', async () => { + const { result, unmount } = renderHook(() => useWakeLock()) + + await act(async () => { + await result.current.request() + }) + + expect(result.current.isActive).toBe(true) + + unmount() + + expect(mockSentinel.release).toHaveBeenCalled() + }) + + it('should re-acquire wake lock on visibility change after auto-release', async () => { + const mockSentinel2 = createMockSentinel() + const requestMock = navigator.wakeLock.request as jest.Mock + requestMock + .mockResolvedValueOnce(mockSentinel) + .mockResolvedValueOnce(mockSentinel2) + + const { result } = renderHook(() => useWakeLock()) + + await act(async () => { + await result.current.request() + }) + + expect(result.current.isActive).toBe(true) + expect(requestMock).toHaveBeenCalledTimes(1) + + // Simulate browser auto-releasing wake lock (e.g. page becomes hidden) + await act(async () => { + await (mockSentinel.release as jest.Mock)() + }) + + expect(result.current.isActive).toBe(false) + + // Simulate page becoming visible again + await act(async () => { + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + }) + + // Should re-acquire + expect(requestMock).toHaveBeenCalledTimes(2) + }) + + it('should not re-acquire wake lock after explicit release', async () => { + const { result } = renderHook(() => useWakeLock()) + + await act(async () => { + await result.current.request() + }) + + expect(result.current.isActive).toBe(true) + + await act(async () => { + await result.current.release() + }) + + // Simulate page becoming visible + await act(async () => { + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + }) + + // Should NOT re-acquire since user explicitly released + expect(navigator.wakeLock.request).toHaveBeenCalledTimes(1) + }) + + it('should not re-acquire wake lock when not active', async () => { + renderHook(() => useWakeLock()) + + await act(async () => { + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + }) + + // Should not request since never requested + expect(navigator.wakeLock.request).not.toHaveBeenCalled() + }) + + it('should handle release error gracefully', async () => { + const error = new Error('Release failed') + const onError = jest.fn() + + const { result } = renderHook(() => useWakeLock({ onError })) + + await act(async () => { + await result.current.request() + }) + + ;(mockSentinel.release as jest.Mock).mockRejectedValueOnce(error) + + await act(async () => { + await result.current.release() + }) + + expect(onError).toHaveBeenCalledWith(error) + }) + + it('should provide stable function references', () => { + const { result, rerender } = renderHook(() => useWakeLock()) + + const initialRequest = result.current.request + const initialRelease = result.current.release + const initialForceRequest = result.current.forceRequest + + rerender() + + expect(result.current.request).toBe(initialRequest) + expect(result.current.release).toBe(initialRelease) + expect(result.current.forceRequest).toBe(initialForceRequest) + }) + + it('should release old sentinel before acquiring new one via forceRequest', async () => { + const mockSentinel2 = createMockSentinel() + const requestMock = navigator.wakeLock.request as jest.Mock + requestMock + .mockResolvedValueOnce(mockSentinel) + .mockResolvedValueOnce(mockSentinel2) + + const { result } = renderHook(() => useWakeLock()) + + await act(async () => { + await result.current.forceRequest() + }) + + expect(result.current.isActive).toBe(true) + + await act(async () => { + await result.current.forceRequest() + }) + + // Old sentinel should have been released + expect(mockSentinel.release).toHaveBeenCalled() + expect(requestMock).toHaveBeenCalledTimes(2) + expect(result.current.isActive).toBe(true) + }) + + it('should defer request when page is not visible', async () => { + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + configurable: true, + }) + + const { result } = renderHook(() => useWakeLock()) + + await act(async () => { + await result.current.request() + }) + + // Should not request immediately since page is hidden + expect(navigator.wakeLock.request).not.toHaveBeenCalled() + expect(result.current.isActive).toBe(false) + + // Simulate page becoming visible + await act(async () => { + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + }) + + // Now should acquire + expect(navigator.wakeLock.request).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/core/src/useWakeLock/index.ts b/packages/core/src/useWakeLock/index.ts new file mode 100644 index 00000000..b651eab4 --- /dev/null +++ b/packages/core/src/useWakeLock/index.ts @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from 'react' +import { useSupported } from '../useSupported' +import { useEvent } from '../useEvent' +import type { UseWakeLock, UseWakeLockOptions } from './interface' + +export const useWakeLock: UseWakeLock = (options: UseWakeLockOptions = {}) => { + const { onRequest, onRelease, onError } = options + + const isSupported = useSupported(() => 'wakeLock' in navigator) + const [isActive, setIsActive] = useState(false) + const sentinelRef = useRef(null) + const requestedTypeRef = useRef(false) + + const forceRequest = useEvent(async () => { + if (!isSupported) + return + try { + await sentinelRef.current?.release() + const sentinel = await navigator.wakeLock.request('screen') + sentinelRef.current = sentinel + setIsActive(true) + sentinel.addEventListener('release', () => { + requestedTypeRef.current = sentinelRef.current?.type ?? false + sentinelRef.current = null + setIsActive(false) + onRelease?.() + }, { once: true }) + onRequest?.() + } + catch (error) { + onError?.(error as Error) + } + }) + + const request = useEvent(async () => { + if (!isSupported) + return + if (document.visibilityState === 'visible') { + await forceRequest() + } + else { + requestedTypeRef.current = 'screen' + } + }) + + const release = useEvent(async () => { + requestedTypeRef.current = false + const s = sentinelRef.current + sentinelRef.current = null + setIsActive(false) + try { + await s?.release() + } + catch (error) { + onError?.(error as Error) + } + }) + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && requestedTypeRef.current) { + requestedTypeRef.current = false + forceRequest() + } + } + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [forceRequest]) + + useEffect(() => { + return () => { + requestedTypeRef.current = false + if (sentinelRef.current) { + sentinelRef.current.release() + sentinelRef.current = null + } + } + }, []) + + return { + isSupported, + isActive, + request, + release, + forceRequest, + } +} diff --git a/packages/core/src/useWakeLock/interface.ts b/packages/core/src/useWakeLock/interface.ts new file mode 100644 index 00000000..078a5761 --- /dev/null +++ b/packages/core/src/useWakeLock/interface.ts @@ -0,0 +1,89 @@ +/** + * @title useWakeLock + * @returns 包含以下元素的对象: + * - isSupported:浏览器是否支持 Wake Lock API。 + * - isActive:当前是否持有唤醒锁。 + * - request:请求唤醒锁(页面可见时立即请求,不可见时延迟到可见时请求)。 + * - forceRequest:强制请求唤醒锁,无论页面是否可见。 + * - release:释放唤醒锁。 + * @returns_en An object with the following elements: + * - isSupported: whether the browser supports the Wake Lock API. + * - isActive: whether a wake lock is currently held. + * - request: request a wake lock (immediately if visible, deferred until visible if hidden). + * - forceRequest: force request a wake lock regardless of visibility. + * - release: release the wake lock. + * @returns_zh-Hant 包含以下元素的對象: + * - isSupported:瀏覽器是否支援 Wake Lock API。 + * - isActive:當前是否持有喚醒鎖。 + * - request:請求喚醒鎖(頁面可見時立即請求,不可見時延遲到可見時請求)。 + * - forceRequest:強制請求喚醒鎖,無論頁面是否可見。 + * - release:釋放喚醒鎖。 + */ +export type UseWakeLock = ( + /** + * @zh 可选参数 + * @zh-Hant 可選參數 + * @en optional params + */ + options?: UseWakeLockOptions +) => UseWakeLockReturn + +/** + * @title UseWakeLockOptions + */ +export interface UseWakeLockOptions { + /** + * @zh 请求成功时的回调 + * @zh-Hant 請求成功時的回調 + * @en callback when wake lock is acquired + */ + onRequest?: () => void + /** + * @zh 释放时的回调 + * @zh-Hant 釋放時的回調 + * @en callback when wake lock is released + */ + onRelease?: () => void + /** + * @zh 发生错误时的回调 + * @zh-Hant 發生錯誤時的回調 + * @en callback when an error occurs + */ + onError?: (error: Error) => void +} + +/** + * @title UseWakeLockReturn + */ +export interface UseWakeLockReturn { + /** + * @zh 浏览器是否支持 Wake Lock API + * @zh-Hant 瀏覽器是否支援 Wake Lock API + * @en whether the browser supports the Wake Lock API + */ + readonly isSupported: boolean + /** + * @zh 当前是否持有唤醒锁 + * @zh-Hant 當前是否持有喚醒鎖 + * @en whether a wake lock is currently held + */ + readonly isActive: boolean + /** + * @zh 请求唤醒锁 + * @zh-Hant 請求喚醒鎖 + * @en request a wake lock + */ + readonly request: () => Promise + /** + * @zh 强制请求唤醒锁,无论页面是否可见 + * @zh-Hant 強制請求喚醒鎖,無論頁面是否可見 + * @en force request a wake lock regardless of page visibility + */ + readonly forceRequest: () => Promise + /** + * @zh 释放唤醒锁 + * @zh-Hant 釋放喚醒鎖 + * @en release the wake lock + */ + readonly release: () => Promise +} diff --git a/packages/website-astro/src/content/docs-zh-hans/browser/useWakeLock.mdx b/packages/website-astro/src/content/docs-zh-hans/browser/useWakeLock.mdx new file mode 100644 index 00000000..5b05fbd0 --- /dev/null +++ b/packages/website-astro/src/content/docs-zh-hans/browser/useWakeLock.mdx @@ -0,0 +1,50 @@ +--- +title: useWakeLock 用法与示例 +sidebar_label: useWakeLock +description: 响应式屏幕唤醒锁 API,防止屏幕变暗或锁定。本文介绍其用法、最佳实践与代码示例。 +--- +# useWakeLock + +响应式屏幕唤醒锁 API,防止屏幕变暗或锁定。 + +## Usage + +```tsx live +function Demo() { + const { isSupported, isActive, request, forceRequest, release } = useWakeLock({ + onRequest: () => console.log("唤醒锁已获取"), + onRelease: () => console.log("唤醒锁已释放"), + onError: (e) => console.error("唤醒锁错误:", e), + }); + + if (!isSupported) { + return
您的浏览器不支持 Wake Lock API。
; + } + + return ( +
+
+ 唤醒锁: {isActive ? "已激活" : "未激活"} +
+
+ + + +
+
+ ); +}; + +``` + +%%API%% diff --git a/packages/website-astro/src/content/docs-zh-hant/browser/useWakeLock.mdx b/packages/website-astro/src/content/docs-zh-hant/browser/useWakeLock.mdx new file mode 100644 index 00000000..d187e8ac --- /dev/null +++ b/packages/website-astro/src/content/docs-zh-hant/browser/useWakeLock.mdx @@ -0,0 +1,50 @@ +--- +title: useWakeLock 用法與示例 +sidebar_label: useWakeLock +description: 響應式螢幕喚醒鎖 API,防止螢幕變暗或鎖定。本文介紹其用法、最佳實踐與代碼示例。 +--- +# useWakeLock + +響應式螢幕喚醒鎖 API,防止螢幕變暗或鎖定。 + +## Usage + +```tsx live +function Demo() { + const { isSupported, isActive, request, forceRequest, release } = useWakeLock({ + onRequest: () => console.log("喚醒鎖已獲取"), + onRelease: () => console.log("喚醒鎖已釋放"), + onError: (e) => console.error("喚醒鎖錯誤:", e), + }); + + if (!isSupported) { + return
您的瀏覽器不支援 Wake Lock API。
; + } + + return ( +
+
+ 喚醒鎖: {isActive ? "已啟用" : "未啟用"} +
+
+ + + +
+
+ ); +}; + +``` + +%%API%% diff --git a/packages/website-astro/src/content/docs/browser/useWakeLock.mdx b/packages/website-astro/src/content/docs/browser/useWakeLock.mdx new file mode 100644 index 00000000..f00adb8e --- /dev/null +++ b/packages/website-astro/src/content/docs/browser/useWakeLock.mdx @@ -0,0 +1,50 @@ +--- +title: useWakeLock – Browser Hook Usage & Examples +sidebar_label: useWakeLock +description: Reactive Screen Wake Lock API. Prevent the screen from dimming or locking. Learn usage patterns, best practices, and code examples for React developers. +--- +# useWakeLock + +Reactive Screen Wake Lock API. Prevent the screen from dimming or locking. + +## Usage + +```tsx live +function Demo() { + const { isSupported, isActive, request, forceRequest, release } = useWakeLock({ + onRequest: () => console.log("Wake lock acquired"), + onRelease: () => console.log("Wake lock released"), + onError: (e) => console.error("Wake lock error:", e), + }); + + if (!isSupported) { + return
Wake Lock API is not supported in your browser.
; + } + + return ( +
+
+ Wake Lock: {isActive ? "Active" : "Inactive"} +
+
+ + + +
+
+ ); +}; + +``` + +%%API%%