feat: Prevent Screen Sleep During Response Generation (#10597)

* feat: prevent screen sleep during response generation

* refactor: screen wake lock functionality during response generation

* chore: import order

* chore: reorder import statements in WakeLockManager component

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
michnovka 2025-11-21 15:14:32 +01:00 committed by GitHub
parent 5ac9ac57cc
commit 040d083088
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 262 additions and 0 deletions

View file

@ -0,0 +1,211 @@
import { useEffect, useRef } from 'react';
/**
* Extended Navigator type that includes the Screen Wake Lock API
* @see https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API
*/
type WakeLockCapableNavigator = Navigator & {
wakeLock?: {
request: (type: WakeLockType) => Promise<WakeLockSentinel>;
};
};
/**
* Checks if we're in a client-side environment (browser)
* Prevents SSR issues by verifying window, navigator, and document exist
*/
const isClientEnvironment =
typeof window !== 'undefined' &&
typeof navigator !== 'undefined' &&
typeof document !== 'undefined';
const getNavigator = () => navigator as WakeLockCapableNavigator;
/**
* Determines if the browser supports the Screen Wake Lock API
* Checking outside component scope for better performance
*/
const supportsWakeLock = isClientEnvironment && 'wakeLock' in navigator;
/**
* Enable/disable debug logging for wake lock operations
* Set to true during development to see wake lock lifecycle events
*/
const DEBUG_WAKE_LOCK = false;
/**
* Custom hook to prevent screen from sleeping during critical operations
* Uses the Screen Wake Lock API to keep the device screen active
*
* @param shouldHold - Boolean flag indicating whether to acquire/hold the wake lock
* @returns void - This hook manages wake lock state internally
*
* @example
* ```tsx
* const isGeneratingResponse = useRecoilValue(anySubmittingSelector);
* useWakeLock(isGeneratingResponse);
* ```
*
* @remarks
* - Automatically handles page visibility changes (reacquires lock when tab becomes visible)
* - Properly cleans up lock on unmount or when shouldHold becomes false
* - Gracefully degrades on browsers without Wake Lock API support
* - Wake locks are automatically released when user switches tabs
*/
export const useWakeLock = (shouldHold: boolean) => {
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
if (!supportsWakeLock) {
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] API not supported in this browser');
}
return;
}
/**
* Flag to prevent operations after effect cleanup
* Essential for avoiding race conditions when:
* - Component unmounts while lock is being acquired
* - shouldHold changes while async operations are in flight
* - Multiple visibility change events fire in quick succession
*/
let cancelled = false;
const { wakeLock } = getNavigator();
if (!wakeLock) {
return;
}
/**
* Releases the currently held wake lock
* Called when: shouldHold becomes false, component unmounts, or before acquiring new lock
*/
const releaseLock = async () => {
if (!wakeLockRef.current) {
return;
}
try {
await wakeLockRef.current.release();
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Lock released successfully');
}
} catch (error) {
console.warn('[WakeLock] release failed', error);
} finally {
wakeLockRef.current = null;
}
};
/**
* Requests a new wake lock from the browser
* Checks multiple conditions before requesting to avoid unnecessary API calls:
* - shouldHold must be true (user wants lock)
* - cancelled must be false (effect still active)
* - document must be visible (API requirement - locks only work in visible tabs)
* - no existing lock (prevent duplicate locks)
*/
const requestLock = async () => {
if (
!shouldHold ||
cancelled ||
document.visibilityState !== 'visible' ||
wakeLockRef.current
) {
return;
}
try {
const sentinel = await wakeLock.request('screen');
wakeLockRef.current = sentinel;
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Lock acquired successfully');
}
/**
* CRITICAL: Recursive re-acquire logic for automatic lock restoration
*
* The browser automatically releases wake locks when:
* - User switches to a different tab
* - User minimizes the browser window
* - Device goes to sleep
* - User navigates to a different page
*
* This handler automatically re-acquires the lock when:
* 1. The lock is released by the browser
* 2. The effect is still active (not cancelled)
* 3. The component still wants to hold the lock (shouldHold is true)
* 4. The tab is visible again (document.visibilityState === 'visible')
*
* This ensures users don't need to manually restart their work after
* switching tabs during long-running operations like AI response generation.
*/
const handleRelease = () => {
wakeLockRef.current = null;
sentinel.removeEventListener('release', handleRelease);
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Lock released, checking if re-acquire needed');
}
if (!cancelled && shouldHold && document.visibilityState === 'visible') {
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Re-acquiring lock');
}
void requestLock();
}
};
sentinel.addEventListener('release', handleRelease);
} catch (error) {
console.warn('[WakeLock] request failed', error);
}
};
/**
* Handles browser tab visibility changes
* When user returns to the tab, re-acquire the lock if it's still needed
* This is necessary because wake locks are automatically released when tab becomes hidden
*/
const handleVisibilityChange = () => {
if (cancelled) {
return;
}
if (DEBUG_WAKE_LOCK) {
console.log('[WakeLock] Visibility changed:', document.visibilityState);
}
if (document.visibilityState === 'visible' && shouldHold) {
void requestLock();
}
};
if (shouldHold) {
void requestLock();
document.addEventListener('visibilitychange', handleVisibilityChange);
} else {
void releaseLock();
}
/**
* Cleanup function runs when:
* - Component unmounts
* - shouldHold changes
* - Effect dependencies change
*
* Sets cancelled flag first to prevent any in-flight operations from completing
* Removes event listeners to prevent memory leaks
* Releases any held wake lock
*/
return () => {
cancelled = true;
document.removeEventListener('visibilitychange', handleVisibilityChange);
void releaseLock();
};
}, [shouldHold]);
};
export default useWakeLock;