diff --git a/configs/test-utils/src/__mocks__/@monkvision/common.tsx b/configs/test-utils/src/__mocks__/@monkvision/common.tsx
index 6bdcc168b..520a1d654 100644
--- a/configs/test-utils/src/__mocks__/@monkvision/common.tsx
+++ b/configs/test-utils/src/__mocks__/@monkvision/common.tsx
@@ -118,6 +118,6 @@ export = {
useAsyncInterval: jest.fn(),
useSearchParams: jest.fn(() => ({ get: jest.fn(() => null) })),
useObjectMemo: jest.fn((obj) => obj),
- usePreventExit: jest.fn(),
getLanguage: jest.fn(),
+ usePreventExit: jest.fn(() => ({ allowRedirect: jest.fn() })),
};
diff --git a/packages/common/README/HOOKS.md b/packages/common/README/HOOKS.md
index 45403abd1..b56aa0f69 100644
--- a/packages/common/README/HOOKS.md
+++ b/packages/common/README/HOOKS.md
@@ -137,14 +137,19 @@ function TestComponent() {
Custom hook used to get a translation function tObj that translates TranslationObjects.
### usePreventExit
-```tsx
+```ts
import { usePreventExit } from '@monkvision/common';
-function TestComponent() {
- usePreventExit();
- return <>...>;
+function MyComponent() {
+ const { allowRedirect } = usePreventExit(true);
+
+ const anyEvent = useCallback(() => {
+ allowRedirect();
+ /** ... */
+ }, [allowRedirect])
}
```
+
This hook is used to prevent the user from leaving the page by displaying a confirmation dialog when the user tries to
leave the page.
diff --git a/packages/common/src/PreventExit/hooks.ts b/packages/common/src/PreventExit/hooks.ts
new file mode 100644
index 000000000..2e556483a
--- /dev/null
+++ b/packages/common/src/PreventExit/hooks.ts
@@ -0,0 +1,25 @@
+import { useEffect, useMemo } from 'react';
+import { PreventExitListenerResult, createPreventExitListener } from './store';
+import { useObjectMemo } from '../hooks';
+
+/**
+ * Custom hook that allows preventing the user from exiting the page or navigating away.
+ *
+ * @param preventExit - A boolean value indicating whether to prevent the user from exiting the page or not.
+ *
+ * @example
+ * ```tsx
+ * function MyComponent() {
+ * const { allowRedirect } = usePreventExit(true);
+ * return
My Component
;
+ * }
+ * ```
+ */
+export function usePreventExit(
+ preventExit: boolean,
+): Pick {
+ const { cleanup, setPreventExit, allowRedirect } = useMemo(createPreventExitListener, []);
+ useEffect(() => setPreventExit(preventExit), [preventExit]);
+ useEffect(() => cleanup, []);
+ return useObjectMemo({ allowRedirect });
+}
diff --git a/packages/common/src/PreventExit/index.ts b/packages/common/src/PreventExit/index.ts
new file mode 100644
index 000000000..4cc90d02b
--- /dev/null
+++ b/packages/common/src/PreventExit/index.ts
@@ -0,0 +1 @@
+export * from './hooks';
diff --git a/packages/common/src/PreventExit/store.ts b/packages/common/src/PreventExit/store.ts
new file mode 100644
index 000000000..5b71caee7
--- /dev/null
+++ b/packages/common/src/PreventExit/store.ts
@@ -0,0 +1,59 @@
+const keys: Array = [];
+const allPreventExitState: Record = {};
+
+function arePreventExitRemaining(): boolean {
+ if (keys.map((key) => allPreventExitState[key]).every((i) => i === false)) {
+ window.onbeforeunload = null;
+ return true;
+ }
+ return false;
+}
+
+function publish(id: symbol, preventExit: boolean): void {
+ allPreventExitState[id] = preventExit;
+ if (!arePreventExitRemaining())
+ window.onbeforeunload = (e) => {
+ e.preventDefault();
+ return 'prevent-exit';
+ };
+}
+
+/**
+ * Returns a listener which can used to calculate the state of prevent exit.
+ */
+export interface PreventExitListenerResult {
+ /**
+ * Callback used to set the value indicating if direct exit of the page is currently allowed or not.
+ */
+ setPreventExit: (preventExit: boolean) => void;
+ /**
+ * Allows the user to leave the page without confirmation temporarily.
+ * This should be used when the developer wants to explicitly allow navigation.
+ */
+ allowRedirect: () => void;
+ /**
+ * Performs garbage collection by removing the preventExit state associated with the component.
+ * This should be used when the component is unmounted.
+ */
+ cleanup: () => void;
+}
+
+/**
+ * Creates a listener function that manages the preventExit state of a component.
+ */
+export function createPreventExitListener(): PreventExitListenerResult {
+ const key = Symbol('PreventExitListener');
+ allPreventExitState[key] = true;
+ keys.push(key);
+
+ return {
+ setPreventExit: (preventExit) => publish(key, preventExit),
+ allowRedirect: () => {
+ window.onbeforeunload = null;
+ },
+ cleanup: () => {
+ delete allPreventExitState[key];
+ arePreventExitRemaining();
+ },
+ };
+}
diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts
index 13e2d1474..8f54beca7 100644
--- a/packages/common/src/hooks/index.ts
+++ b/packages/common/src/hooks/index.ts
@@ -10,5 +10,4 @@ export * from './useSearchParams';
export * from './useInterval';
export * from './useAsyncInterval';
export * from './useObjectMemo';
-export * from './usePreventExit';
export * from './useThumbnail';
diff --git a/packages/common/src/hooks/usePreventExit.ts b/packages/common/src/hooks/usePreventExit.ts
deleted file mode 100644
index 528a532a6..000000000
--- a/packages/common/src/hooks/usePreventExit.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useEffect } from 'react';
-
-/**
- * Hook that prevents the user from leaving the page without confirmation.
- *
- * @example
- * ```tsx
- * import { usePreventExit } from '@monkvision/common';
- *
- * function MyComponent() {
- * usePreventExit();
- * return My Component
;
- * }
- * ```
- */
-export function usePreventExit() {
- useEffect(() => {
- window.onbeforeunload = () => {
- return 'Are you sure you want to leave?';
- };
- return () => {
- window.onbeforeunload = null;
- };
- }, []);
-}
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
index c93c4d6a0..cbc74b6ab 100644
--- a/packages/common/src/index.ts
+++ b/packages/common/src/index.ts
@@ -1,6 +1,7 @@
+export * from './PreventExit';
+export * from './apps';
+export * from './hooks';
export * from './i18n';
export * from './state';
export * from './theme';
export * from './utils';
-export * from './hooks';
-export * from './apps';
diff --git a/packages/common/test/PreventExit/hooks.test.ts b/packages/common/test/PreventExit/hooks.test.ts
new file mode 100644
index 000000000..f051560e4
--- /dev/null
+++ b/packages/common/test/PreventExit/hooks.test.ts
@@ -0,0 +1,46 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { usePreventExit } from '../../src/PreventExit/hooks';
+import { createPreventExitListener } from '../../src/PreventExit/store';
+
+jest.mock('../../src/PreventExit/store', () => ({
+ createPreventExitListener: jest.fn(() => ({
+ cleanup: jest.fn(),
+ setPreventExit: jest.fn(),
+ allowRedirect: jest.fn(),
+ })),
+}));
+describe('PreventExit hook usePreventExit', () => {
+ let spyCreatePreventExitListener: jest.SpyInstance>;
+ beforeEach(() => {
+ spyCreatePreventExitListener = jest.spyOn(
+ { createPreventExitListener },
+ 'createPreventExitListener',
+ );
+ });
+ afterEach(() => jest.clearAllMocks());
+
+ it('should clean up when unmount', () => {
+ const { unmount } = renderHook(() => usePreventExit(true));
+ unmount();
+ expect(spyCreatePreventExitListener.mock.results.at(-1)?.value.cleanup).toHaveBeenCalledTimes(
+ 1,
+ );
+ });
+
+ it('should set preventExit when value changes', async () => {
+ const { rerender } = renderHook((props) => usePreventExit(props), {
+ initialProps: true,
+ });
+ expect(
+ spyCreatePreventExitListener.mock.results.at(-1)?.value.setPreventExit,
+ ).toHaveBeenCalledTimes(1);
+ rerender(false);
+ expect(
+ spyCreatePreventExitListener.mock.results.at(-1)?.value.setPreventExit,
+ ).toHaveBeenCalledTimes(2);
+ rerender(false);
+ expect(
+ spyCreatePreventExitListener.mock.results.at(-1)?.value.setPreventExit,
+ ).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/common/test/PreventExit/store.test.ts b/packages/common/test/PreventExit/store.test.ts
new file mode 100644
index 000000000..725223c48
--- /dev/null
+++ b/packages/common/test/PreventExit/store.test.ts
@@ -0,0 +1,49 @@
+import { createPreventExitListener } from '../../src/PreventExit/store';
+
+describe('preventExitStore', () => {
+ let listener1: ReturnType;
+ let listener2: ReturnType;
+
+ beforeEach(() => {
+ listener1 = createPreventExitListener();
+ listener2 = createPreventExitListener();
+ });
+
+ afterEach(() => {
+ listener1.cleanup();
+ listener2.cleanup();
+ });
+
+ it('should prevent exit: listener 1', () => {
+ listener1.setPreventExit(true);
+ listener2.setPreventExit(true);
+ expect(window.onbeforeunload).not.toBe(null);
+ listener1.setPreventExit(true);
+ listener2.setPreventExit(false);
+ expect(window.onbeforeunload).not.toBe(null);
+ listener1.setPreventExit(false);
+ listener2.setPreventExit(true);
+ expect(window.onbeforeunload).not.toBe(null);
+ listener1.setPreventExit(false);
+ listener2.setPreventExit(false);
+ expect(window.onbeforeunload).toBe(null);
+ });
+
+ it('should allow redirect: listener 1', () => {
+ const preventExit = [true, false];
+ preventExit.forEach((i) => {
+ preventExit.forEach((j) => {
+ listener1.setPreventExit(i);
+ listener2.setPreventExit(j);
+ listener1.allowRedirect();
+ expect(window.onbeforeunload).toBe(null);
+ });
+ });
+ });
+
+ it('should allow redirect: lister 2', () => {
+ listener2.cleanup();
+ listener1.cleanup();
+ expect(window.onbeforeunload).toBe(null);
+ });
+});
diff --git a/packages/common/test/hooks/usePreventExit.test.ts b/packages/common/test/hooks/usePreventExit.test.ts
deleted file mode 100644
index 968b8163d..000000000
--- a/packages/common/test/hooks/usePreventExit.test.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { renderHook } from '@testing-library/react-hooks';
-import { usePreventExit } from '../../src';
-
-describe('usePreventExit hook', () => {
- it('should properly set and unset the onbeforeunload event handler', () => {
- const { unmount } = renderHook(usePreventExit);
- expect(window.onbeforeunload).toBeDefined();
- unmount();
- expect(window.onbeforeunload).toBeNull();
- });
-
- it('should prompt the user when trying to leave the page', () => {
- const { unmount } = renderHook(usePreventExit);
- const event = new Event('beforeunload', { cancelable: true });
- const returnValue = window.onbeforeunload?.(event);
- expect(returnValue).toEqual('Are you sure you want to leave?');
- unmount();
- });
-});
diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx
index 7e72115c1..9b60fdca7 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx
+++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx
@@ -4,6 +4,7 @@ import {
useI18nSync,
useLoadingState,
useObjectMemo,
+ usePreventExit,
useWindowDimensions,
} from '@monkvision/common';
import {
@@ -218,6 +219,7 @@ export function PhotoCapture({
}
setCurrentScreen(PhotoCaptureScreen.CAMERA);
};
+ const { allowRedirect } = usePreventExit(sightState.sightsTaken.length !== 0);
const handleGalleryValidate = () => {
startTasks()
.then(() => {
@@ -226,6 +228,7 @@ export function PhotoCapture({
captureCompleted: true,
sightSelected: 'inspection-completed',
});
+ allowRedirect();
onComplete?.();
})
.catch((err) => {
@@ -236,7 +239,6 @@ export function PhotoCapture({
const isViolatingEnforcedOrientation =
enforceOrientation &&
(enforceOrientation === DeviceOrientation.PORTRAIT) !== dimensions.isPortrait;
-
const hudProps: Omit = {
sights,
selectedSight: sightState.selectedSight,
diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx
index f1767b6fa..5d873443c 100644
--- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx
+++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx
@@ -51,12 +51,12 @@ jest.mock('../../src/PhotoCapture/hooks', () => ({
useTracking: jest.fn(),
}));
-import { act, render, waitFor } from '@testing-library/react';
import { Camera } from '@monkvision/camera-web';
-import { expectPropsOnChildMock } from '@monkvision/test-utils';
-import { useI18nSync, useLoadingState } from '@monkvision/common';
+import { useI18nSync, useLoadingState, usePreventExit } from '@monkvision/common';
import { BackdropDialog, InspectionGallery } from '@monkvision/common-ui-web';
import { useMonitoring } from '@monkvision/monitoring';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import { act, render, waitFor } from '@testing-library/react';
import { PhotoCapture, PhotoCaptureHUD, PhotoCaptureProps } from '../../src';
import {
useAdaptiveCameraConfig,
@@ -447,4 +447,11 @@ describe('PhotoCapture component', () => {
unmount();
});
+
+ it('should ask the user for confirmation before exit', () => {
+ const props = createProps();
+ const { unmount } = render();
+ expect(usePreventExit).toHaveBeenCalled();
+ unmount();
+ });
});