Skip to content
Merged
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 configs/test-utils/src/__mocks__/@monkvision/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,5 @@ export = {
alpha: 0,
requestCompassPermission: jest.fn(() => Promise.resolve()),
})),
useSafeTimeout: jest.fn(() => jest.fn()),
};
10 changes: 10 additions & 0 deletions packages/camera-web/src/Camera/hooks/useUserMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export function useUserMedia(
constraints: MediaStreamConstraints,
videoRef: RefObject<HTMLVideoElement> | null,
): UserMediaResult {
const streamRef = useRef<MediaStream | null>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [dimensions, setDimensions] = useState<PixelDimensions | null>(null);
const [isLoading, setIsLoading] = useState(false);
Expand Down Expand Up @@ -276,6 +277,7 @@ export function useUserMedia(
str?.addEventListener('inactive', onStreamInactive);
if (isMounted()) {
setStream(str);
streamRef.current = str;
setDimensions(getStreamDimensions(str, true));
setIsLoading(false);
setAvailableCameraDevices(deviceDetails.availableDevices);
Expand Down Expand Up @@ -332,6 +334,14 @@ export function useUserMedia(
}
}, [stream, videoRef]);

useEffect(() => {
return () => {
streamRef.current?.getTracks().forEach((track) => {
track.stop();
});
};
}, []);

return useObjectMemo({
getUserMedia,
stream,
Expand Down
60 changes: 60 additions & 0 deletions packages/camera-web/test/Camera/hooks/useUserMedia.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,4 +528,64 @@ describe('useUserMedia hook', () => {
});
unmount();
});

it('should stop all tracks when the component unmounts', async () => {
const videoRef = { current: {} } as RefObject<HTMLVideoElement>;
const { result, unmount } = renderUseUserMedia({ constraints: {}, videoRef });

await waitFor(() => {
expect(result.current.stream).toEqual(gumMock?.stream);
});

expect(gumMock?.tracks).toHaveLength(1);
expect(gumMock?.tracks[0].stop).toBeDefined();

unmount();

gumMock?.tracks.forEach((track) => {
expect(track.stop).toHaveBeenCalledTimes(1);
});
});

it('should stop tracks when unmounting even if stream is null', () => {
const videoRef = { current: {} } as RefObject<HTMLVideoElement>;

// Create a mock stream that returns null for getTracks
const nullStreamMock = {
getTracks: jest.fn(() => null),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
} as unknown as MediaStream;

mockGetUserMedia({
createMock: () => jest.fn(() => Promise.resolve(nullStreamMock)),
});

const { unmount } = renderUseUserMedia({ constraints: {}, videoRef });

// Unmount should not throw even with null tracks
expect(() => unmount()).not.toThrow();
});

it('should call stop on each track exactly once during cleanup', async () => {
const videoRef = { current: {} } as RefObject<HTMLVideoElement>;
const { result, unmount } = renderUseUserMedia({ constraints: {}, videoRef });

await waitFor(() => {
expect(result.current.stream).toEqual(gumMock?.stream);
});

// Verify initial state - no tracks stopped yet
gumMock?.tracks.forEach((track) => {
expect(track.stop).not.toHaveBeenCalled();
});

// Unmount the component
unmount();

// Verify each track was stopped exactly once
gumMock?.tracks.forEach((track) => {
expect(track.stop).toHaveBeenCalledTimes(1);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { InteractiveStatus } from '@monkvision/types';
import { CSSProperties, useState } from 'react';
import { useSafeTimeout } from '@monkvision/common';
import { styles, TAKE_PICTURE_BUTTON_COLORS } from './TakePictureButton.styles';

/**
Expand Down Expand Up @@ -33,11 +34,12 @@ export function useTakePictureButtonStyle(
params: MonkTakePictureButtonStyleParams,
): TakePictureButtonStyles {
const [isPressed, setIsPressed] = useState(false);
const setSafeTimeout = useSafeTimeout();
const borderWidth = (params.size * (1 - INNER_BUTTON_SIZE_RATIO)) / 4;

const animateClick = () => {
setIsPressed(true);
setTimeout(() => setIsPressed(false), PRESS_ANIMATION_DURATION_MS);
setSafeTimeout(() => setIsPressed(false), PRESS_ANIMATION_DURATION_MS);
};

const buttonStyles = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
jest.mock('@monkvision/common');

import { fireEvent, render, screen } from '@testing-library/react';
import {
expectComponentToPassDownClassNameToHTMLElement,
Expand Down
13 changes: 13 additions & 0 deletions packages/common/README/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,19 @@ This hook returns takes a `ResponsiveStyleProperties` declarations object (see t
`@monkvision/types` package for more details) containing a media query and returns either the CSSProperties contained in
the type, or `null` if the query conditions are not met. Note that if there are no query, the style will be applied.


### useSafeTimeout
```tsx
import { sights } from '@monkvision/sights';
import { useSafeTimeout } from '@monkvision/common';

function TestComponent() {
const setSafeTimeout = useSafeTimeout();
setSafeTimeout(() => console.log('test'), 1000);
}
```
Custom hook that provides a safe way to use setTimeout.

### useSearchParams
```tsx
import { sights } from '@monkvision/sights';
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './useObjectMemo';
export * from './useForm';
export * from './useIsMounted';
export * from './useDeviceOrientation';
export * from './useSafeTimeout';
26 changes: 26 additions & 0 deletions packages/common/src/hooks/useSafeTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useCallback, useEffect, useRef } from 'react';

/**
* A custom hook that provides a safe way to use setTimeout.
*/
export function useSafeTimeout() {
const isMounted = useRef(true);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
return () => {
isMounted.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);

return useCallback((callback: () => void, delay: number) => {
timeoutRef.current = setTimeout(() => {
if (isMounted.current) {
callback();
}
}, delay);
}, []);
}
161 changes: 161 additions & 0 deletions packages/common/test/hooks/useSafeTimeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useSafeTimeout } from '../../src';

jest.useFakeTimers();

describe('useSafeTimeout hook', () => {
afterEach(() => {
jest.clearAllTimers();
});

afterAll(() => {
jest.useRealTimers();
});

it('should execute callback after specified delay', () => {
const { result } = renderHook(() => useSafeTimeout());
const callback = jest.fn();

act(() => {
result.current(callback, 1000);
});

expect(callback).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(1000);
});

expect(callback).toHaveBeenCalledTimes(1);
});

it('should not execute callback if component unmounts before delay', () => {
const { result, unmount } = renderHook(() => useSafeTimeout());
const callback = jest.fn();

act(() => {
result.current(callback, 1000);
});

unmount();

act(() => {
jest.advanceTimersByTime(1000);
});

expect(callback).not.toHaveBeenCalled();
});

it('should allow multiple timeouts to run if not overridden', () => {
const { result } = renderHook(() => useSafeTimeout());
const callback1 = jest.fn();
const callback2 = jest.fn();

act(() => {
result.current(callback1, 1000);
});

act(() => {
result.current(callback2, 500);
});

act(() => {
jest.advanceTimersByTime(500);
});

expect(callback2).toHaveBeenCalledTimes(1);
expect(callback1).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(500);
});

expect(callback1).toHaveBeenCalledTimes(1);
expect(callback2).toHaveBeenCalledTimes(1);
});

it('should handle multiple timeouts in sequence', () => {
const { result } = renderHook(() => useSafeTimeout());
const callback1 = jest.fn();
const callback2 = jest.fn();

act(() => {
result.current(callback1, 500);
});

act(() => {
jest.advanceTimersByTime(500);
});

expect(callback1).toHaveBeenCalledTimes(1);

act(() => {
result.current(callback2, 300);
});

act(() => {
jest.advanceTimersByTime(300);
});

expect(callback2).toHaveBeenCalledTimes(1);
});

it('should handle zero delay', () => {
const { result } = renderHook(() => useSafeTimeout());
const callback = jest.fn();

act(() => {
result.current(callback, 0);
});

act(() => {
jest.advanceTimersByTime(0);
});

expect(callback).toHaveBeenCalledTimes(1);
});

it('should clean up timeout on unmount', () => {
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
const { result, unmount } = renderHook(() => useSafeTimeout());
const callback = jest.fn();

act(() => {
result.current(callback, 1000);
});

unmount();

expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});

it('should return the same function reference across renders', () => {
const { result, rerender } = renderHook(() => useSafeTimeout());
const firstReference = result.current;

rerender();
const secondReference = result.current;

expect(firstReference).toBe(secondReference);
});

it('should handle callback that throws an error', () => {
const { result } = renderHook(() => useSafeTimeout());
const errorCallback = jest.fn(() => {
throw new Error('Test error');
});

act(() => {
result.current(errorCallback, 100);
});

expect(() => {
act(() => {
jest.advanceTimersByTime(100);
});
}).toThrow('Test error');

expect(errorCallback).toHaveBeenCalledTimes(1);
});
});