Skip to content

fix: safari 13 exception thrown in useScreenOrientation, add enabled … #3

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jan 4, 2025
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
15 changes: 0 additions & 15 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,6 @@ jobs:
- name: "Test"
run: yarn test:coverage --reporter='github-actions' --reporter='junit' --outputFile='./coverage/test-report.junit.xml' --reporter=default

- name: "Upload coverage to Codecov"
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage/test-report.junit.xml
fail_ci_if_error: true

- name: "Upload test results to Codecov"
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage/test-report.junit.xml
fail_ci_if_error: true

dependabot-merge:
name: "Dependabot automerge"
runs-on: ubuntu-latest
Expand Down
66 changes: 57 additions & 9 deletions src/useMediaQuery/index.dom.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import {act, renderHook} from '@testing-library/react-hooks/dom';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {type Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {useMediaQuery} from '../index.js';

describe('useMediaQuery', () => {
const matchMediaMock = vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
const matchMediaMock = vi.fn((query: string) => (
query === '(orientation: unsupported)' ?
undefined :
{
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as unknown as MediaQueryList & {
matches: boolean;
addEventListener: Mock;
removeEventListener: Mock;
dispatchEvent: Mock;
});

vi.stubGlobal('matchMedia', matchMediaMock);

Expand All @@ -30,6 +38,34 @@ describe('useMediaQuery', () => {
expect(result.error).toBeUndefined();
});

it('should return undefined and not thrown on unsupported when not enabled', () => {
vi.stubGlobal('console', {
error(error: string) {
throw new Error(error);
},
});
const {result, rerender, unmount} = renderHook(() => useMediaQuery('max-width : 768px', {enabled: false}));
const {result: result2, rerender: rerender2, unmount: unmount2} = renderHook(() => useMediaQuery('(orientation: unsupported)', {enabled: false}));
expect(result.error).toBeUndefined();
expect(result.current).toBe(undefined);
expect(result2.error).toBeUndefined();
expect(result2.current).toBe(undefined);
rerender('max-width : 768px');
rerender2('(orientation: unsupported)');
expect(result.error).toBeUndefined();
expect(result.current).toBe(undefined);
expect(result2.current).toBe(undefined);
expect(result2.error).toBeUndefined();
unmount();
unmount2();
expect(result.error).toBeUndefined();
expect(result.current).toBe(undefined);
expect(result2.error).toBeUndefined();
expect(result2.current).toBe(undefined);
vi.unstubAllGlobals();
vi.stubGlobal('matchMedia', matchMediaMock);
});

it('should return undefined on first render, if initializeWithValue is false', () => {
const {result} = renderHook(() =>
useMediaQuery('max-width : 768px', {initializeWithValue: false}));
Expand Down Expand Up @@ -147,4 +183,16 @@ describe('useMediaQuery', () => {
unmount1();
expect(mql.removeEventListener).toHaveBeenCalledTimes(1);
});

it('should not throw when media query is not supported', () => {
const {result, unmount, rerender} = renderHook(() => useMediaQuery('(orientation: unsupported)', {initializeWithValue: true}));
expect(result.error).toBeUndefined();
expect(result.current).toBe(undefined);
rerender();
expect(result.error).toBeUndefined();
expect(result.current).toBe(undefined);
unmount();
expect(result.error).toBeUndefined();
expect(result.current).toBe(undefined);
});
});
7 changes: 7 additions & 0 deletions src/useMediaQuery/index.ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,11 @@ describe('useMediaQuery', () => {
useMediaQuery('max-width : 768px', {initializeWithValue: false}));
expect(result.current).toBeUndefined();
});

it('should return undefined on first render, when not enabled and initializeWithValue is set to true', () => {
const {result} = renderHook(() =>
useMediaQuery('max-width : 768px', {initializeWithValue: true, enabled: false}));
expect(result.error).toBeUndefined();
expect(result.current).toBeUndefined();
});
});
44 changes: 40 additions & 4 deletions src/useMediaQuery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,43 @@ import {isBrowser} from '../util/const.js';

const queriesMap = new Map<
string,
{mql: MediaQueryList; dispatchers: Set<Dispatch<boolean>>; listener: () => void}
{
mql: MediaQueryList;
dispatchers: Set<Dispatch<boolean>>;
listener: () => void;
}
>();

type QueryStateSetter = (matches: boolean) => void;

const createQueryEntry = (query: string) => {
const mql = matchMedia(query);
if (!mql) {
if (
typeof process === 'undefined' ||
process.env === undefined ||
process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'test'
) {
console.error(`error: matchMedia('${query}') returned null, this means that the browser does not support this query or the query is invalid.`);
}

return {
mql: {
onchange: null,
matches: undefined as unknown as boolean,
media: query,
addEventListener: () => undefined as void,
addListener: () => undefined as void,
removeListener: () => undefined as void,
removeEventListener: () => undefined as void,
dispatchEvent: () => false as boolean,
},
dispatchers: new Set<Dispatch<boolean>>(),
listener: () => undefined as void,
};
}

const dispatchers = new Set<QueryStateSetter>();
const listener = () => {
for (const d of dispatchers) {
Expand Down Expand Up @@ -61,6 +91,7 @@ const queryUnsubscribe = (query: string, setState: QueryStateSetter): void => {

type UseMediaQueryOptions = {
initializeWithValue?: boolean;
enabled?: boolean;
};

/**
Expand All @@ -70,19 +101,20 @@ type UseMediaQueryOptions = {
* @param options Hook options:
* `initializeWithValue` (default: `true`) - Determine media query match state on first render. Setting
* this to false will make the hook yield `undefined` on first render.
* `enabled` (default: `true`) - Enable or disable the hook.
*/
export function useMediaQuery(
query: string,
options: UseMediaQueryOptions = {},
): boolean | undefined {
let {initializeWithValue = true} = options;
let {initializeWithValue = true, enabled = true} = options;

if (!isBrowser) {
initializeWithValue = false;
}

const [state, setState] = useState<boolean | undefined>(() => {
if (initializeWithValue) {
if (initializeWithValue && enabled) {
let entry = queriesMap.get(query);
if (!entry) {
entry = createQueryEntry(query);
Expand All @@ -94,12 +126,16 @@ export function useMediaQuery(
});

useEffect(() => {
if (!enabled) {
return;
}

querySubscribe(query, setState);

return () => {
queryUnsubscribe(query, setState);
};
}, [query]);
}, [query, enabled]);

return state;
}
7 changes: 7 additions & 0 deletions src/useScreenOrientation/index.ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,11 @@ describe('useScreenOrientation', () => {
const {result} = renderHook(() => useScreenOrientation({initializeWithValue: false}));
expect(result.error).toBeUndefined();
});

it('should return undefined on first render, when not enabled and initializeWithValue is set to true', () => {
const {result} = renderHook(() =>
useScreenOrientation({initializeWithValue: true, enabled: false}));
expect(result.error).toBeUndefined();
expect(result.current).toBeUndefined();
});
});
6 changes: 6 additions & 0 deletions src/useScreenOrientation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ export type ScreenOrientation = 'portrait' | 'landscape';

type UseScreenOrientationOptions = {
initializeWithValue?: boolean;
enabled?: boolean;
};

/**
* Checks if screen is in `portrait` or `landscape` orientation.
*
* As `Screen Orientation API` is still experimental and not supported by Safari, this
* hook uses CSS3 `orientation` media-query to check screen orientation.
* @param options Hook options:
* `initializeWithValue` (default: `true`) - Determine screen orientation on first render. Setting
* this to false will make the hook yield `undefined` on first render.
* `enabled` (default: `true`) - Enable or disable the hook.
*/
export function useScreenOrientation(
options?: UseScreenOrientationOptions,
): ScreenOrientation | undefined {
const matches = useMediaQuery('(orientation: portrait)', {
initializeWithValue: options?.initializeWithValue ?? true,
enabled: options?.enabled,
});

return matches === undefined ? undefined : (matches ? 'portrait' : 'landscape');
Expand Down
Loading