Skip to content

Commit 5d1053d

Browse files
authored
fix(useMediaQuery): matches immediately in a DOM environment (#11)
1 parent dcc9436 commit 5d1053d

15 files changed

+386
-37
lines changed

.travis.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
language: node_js
2+
node_js:
3+
- stable
4+
5+
cache: yarn
6+
7+
after_script:
8+
- node_modules/.bin/codecov
9+
10+
branches:
11+
only:
12+
- master

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"author": "Jason Quense <[email protected]>",
99
"license": "MIT",
1010
"scripts": {
11-
"test": "jest",
11+
"test": "jest --coverage",
1212
"tdd": "jest --watch",
1313
"build:pick": "cherry-pick --name=@restart/hooks --cwd=lib --input-dir=../src --cjs-dir=cjs --esm-dir=esm",
1414
"build": "rimraf lib && 4c build src && yarn build:pick",
@@ -49,12 +49,14 @@
4949
"@types/react": "^16.9.1",
5050
"babel-jest": "^24.8.0",
5151
"cherry-pick": "^0.4.0",
52+
"codecov": "^3.5.0",
5253
"enzyme": "^3.10.0",
5354
"enzyme-adapter-react-16": "^1.14.0",
5455
"eslint": "^5.16.0",
5556
"husky": "^3.0.3",
5657
"jest": "^24.8.0",
5758
"lint-staged": "^9.2.1",
59+
"mq-polyfill": "^1.1.8",
5860
"prettier": "^1.18.2",
5961
"react": "^16.9.0",
6062
"react-dom": "^16.9.0",

src/useBreakpoint.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import useMediaQuery from './useMediaQuery'
22
import { useMemo } from 'react'
33

4-
export interface AliasedScale<T> {
5-
[idx: number]: T
6-
[alias: string]: T
7-
}
8-
9-
type BreakpointDirection = 'up' | 'down' | true
4+
export type BreakpointDirection = 'up' | 'down' | true
105

11-
type BreakpointMap<TKey extends string> = Partial<
6+
export type BreakpointMap<TKey extends string> = Partial<
127
Record<TKey, BreakpointDirection>
138
>
149

@@ -40,7 +35,9 @@ export function createBreakpointHook<TKey extends string>(
4035
const names = Object.keys(breakpointValues) as TKey[]
4136

4237
function and(query: string, next: string) {
43-
if (query === next || typeof next === 'boolean') return next
38+
if (query === next) {
39+
return next
40+
}
4441
return query ? `${query} and ${next}` : next
4542
}
4643

@@ -53,7 +50,7 @@ export function createBreakpointHook<TKey extends string>(
5350
let value = breakpointValues[next]
5451

5552
if (typeof value === 'number') value = `${value - 0.2}px`
56-
else value = `calc$(${value} - 0.2px)`
53+
else value = `calc(${value} - 0.2px)`
5754

5855
return `(max-width: ${value})`
5956
}
@@ -139,7 +136,8 @@ export function createBreakpointHook<TKey extends string>(
139136
return useBreakpoint
140137
}
141138

142-
type DefaultBreakpoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
139+
export type DefaultBreakpoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
140+
export type DefaultBreakpointMap = BreakpointMap<DefaultBreakpoints>
143141

144142
const useBreakpoint = createBreakpointHook<DefaultBreakpoints>({
145143
xs: 0,

src/useMap.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ import useForceUpdate from './useForceUpdate'
22
import useStableMemo from './useStableMemo'
33

44
export class ObservableMap<K, V> extends Map<K, V> {
5+
private readonly listener: (map: ObservableMap<K, V>) => void
6+
57
constructor(
6-
private readonly listener: (map: ObservableMap<K, V>) => void,
8+
listener: (map: ObservableMap<K, V>) => void,
79
init?: Iterable<Readonly<[K, V]>>,
810
) {
911
super(init as any)
12+
13+
this.listener = listener
1014
}
1115

1216
set(key: K, value: V): this {

src/useMediaQuery.tsx

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
1-
import { useState } from 'react'
21
import useEffect from './useIsomorphicEffect'
2+
import { useState } from 'react'
33

44
interface RefCountedMediaQueryList extends MediaQueryList {
55
refCount: number
66
}
77

8+
const isBool = (a: any): a is boolean => typeof a === 'boolean'
9+
810
const matchers = new Map<string, RefCountedMediaQueryList>()
911

12+
const getMatcher = (
13+
query: string | null,
14+
): RefCountedMediaQueryList | undefined => {
15+
if (!query || typeof window == 'undefined') return undefined
16+
17+
let mql = matchers.get(query)
18+
if (!mql) {
19+
mql = window.matchMedia(query) as RefCountedMediaQueryList
20+
mql.refCount = 0
21+
matchers.set(mql.media, mql)
22+
}
23+
return mql
24+
}
1025
/**
1126
* Match a media query and get updates as the match changes. The media string is
1227
* passed directly to `window.matchMedia` and run as a Layout Effect, so initial
@@ -25,19 +40,15 @@ const matchers = new Map<string, RefCountedMediaQueryList>()
2540
*
2641
* @param query A media query
2742
*/
28-
export default function useMediaQuery(query: string) {
29-
const [matches, setMatches] = useState(false)
43+
export default function useMediaQuery(query: string | null) {
44+
const mql = getMatcher(query)
3045

31-
useEffect(() => {
32-
if (typeof query === 'boolean') {
33-
return setMatches(query)
34-
}
46+
const [matches, setMatches] = useState(() => (mql ? mql.matches : false))
3547

36-
let mql = matchers.get(query)
48+
useEffect(() => {
49+
let mql = getMatcher(query)
3750
if (!mql) {
38-
mql = window.matchMedia(query) as RefCountedMediaQueryList
39-
mql.refCount = 0
40-
matchers.set(mql.media, mql)
51+
return setMatches(false)
4152
}
4253

4354
const handleChange = () => {
@@ -50,11 +61,10 @@ export default function useMediaQuery(query: string) {
5061
handleChange()
5162

5263
return () => {
53-
if (!mql) return
54-
mql.removeListener(handleChange)
55-
mql.refCount--
56-
if (mql.refCount <= 0) {
57-
matchers.delete(mql.media)
64+
mql!.removeListener(handleChange)
65+
mql!.refCount--
66+
if (mql!.refCount <= 0) {
67+
matchers.delete(mql!.media)
5868
}
5969
mql = undefined
6070
}

src/useSet.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import useForceUpdate from './useForceUpdate'
22
import useStableMemo from './useStableMemo'
33

44
export class ObservableSet<V> extends Set<V> {
5-
constructor(
6-
private readonly listener: (map: ObservableSet<V>) => void,
7-
init?: Iterable<V>,
8-
) {
5+
private readonly listener: (map: ObservableSet<V>) => void
6+
7+
constructor(listener: (map: ObservableSet<V>) => void, init?: Iterable<V>) {
98
super(init as any)
9+
10+
this.listener = listener
1011
}
1112

1213
add(value: V): this {

test/setup.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
1-
import Enzyme from 'enzyme'
21
import Adapter from 'enzyme-adapter-react-16'
2+
import Enzyme from 'enzyme'
3+
import matchMediaPolyfill from 'mq-polyfill'
34

45
Enzyme.configure({ adapter: new Adapter() })
6+
7+
// https://github.com/bigslycat/mq-polyfill
8+
9+
if (typeof window !== 'undefined') {
10+
matchMediaPolyfill(window)
11+
12+
/**
13+
* For dispatching resize event
14+
* we must implement window.resizeTo in jsdom
15+
*/
16+
window.resizeTo = function resizeTo(width, height) {
17+
Object.assign(this, {
18+
innerWidth: width,
19+
innerHeight: height,
20+
outerWidth: width,
21+
outerHeight: height,
22+
}).dispatchEvent(new this.Event('resize'))
23+
}
24+
}

test/useBreakpoint.test.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import useBreakpoint, {
2+
DefaultBreakpointMap,
3+
createBreakpointHook,
4+
} from '../src/useBreakpoint'
5+
6+
import React from 'react'
7+
import { mount } from 'enzyme'
8+
9+
interface Props {
10+
breakpoint: DefaultBreakpointMap
11+
}
12+
13+
describe('useBreakpoint', () => {
14+
let matchMediaSpy: jest.SpyInstance<MediaQueryList, [string]>
15+
16+
beforeEach(() => {
17+
matchMediaSpy = jest.spyOn(window, 'matchMedia')
18+
window.resizeTo(1024, window.innerHeight)
19+
})
20+
21+
afterEach(() => {
22+
matchMediaSpy.mockRestore()
23+
})
24+
25+
it.each`
26+
width | expected | config
27+
${1024} | ${false} | ${{ md: 'down', sm: 'up' }}
28+
${600} | ${true} | ${{ md: 'down', sm: 'up' }}
29+
${991} | ${true} | ${{ md: 'down' }}
30+
${992} | ${false} | ${{ md: 'down' }}
31+
${768} | ${true} | ${{ md: 'up' }}
32+
${576} | ${false} | ${{ xs: 'down' }}
33+
${576} | ${false} | ${{ md: true }}
34+
${800} | ${true} | ${{ md: true }}
35+
${1000} | ${false} | ${{ md: true }}
36+
${576} | ${false} | ${'md'}
37+
${800} | ${true} | ${'md'}
38+
${1000} | ${false} | ${'md'}
39+
${500} | ${true} | ${{ xs: 'down' }}
40+
${0} | ${true} | ${{ xs: 'up' }}
41+
`(
42+
'should match: $expected with config: $config at window width: $width',
43+
({ width, expected, config }) => {
44+
let actual: boolean
45+
46+
window.resizeTo(width, window.innerHeight)
47+
48+
const Wrapper = () => {
49+
actual = useBreakpoint(config)
50+
return null
51+
}
52+
mount(<Wrapper />).unmount()
53+
expect(actual).toEqual(expected)
54+
},
55+
)
56+
57+
it('should assume pixels for number values', () => {
58+
const useCustomBreakpoint = createBreakpointHook({
59+
xs: 0,
60+
sm: 400,
61+
md: 700,
62+
})
63+
64+
const Wrapper = () => {
65+
useCustomBreakpoint('sm')
66+
return null
67+
}
68+
mount(<Wrapper />).unmount()
69+
70+
expect(matchMediaSpy).toBeCalled()
71+
expect(matchMediaSpy.mock.calls[0][0]).toEqual(
72+
'(min-width: 400px) and (max-width: 699.8px)',
73+
)
74+
})
75+
76+
it('should use calc for string values', () => {
77+
const useCustomBreakpoint = createBreakpointHook({
78+
xs: 0,
79+
sm: '40rem',
80+
md: '70rem',
81+
})
82+
83+
const Wrapper = () => {
84+
useCustomBreakpoint('sm')
85+
return null
86+
}
87+
mount(<Wrapper />).unmount()
88+
89+
expect(matchMediaSpy).toBeCalled()
90+
expect(matchMediaSpy.mock.calls[0][0]).toEqual(
91+
'(min-width: 40rem) and (max-width: calc(70rem - 0.2px))',
92+
)
93+
})
94+
95+
it('should flatten media', () => {
96+
const useCustomBreakpoint = createBreakpointHook({
97+
sm: 400,
98+
md: 400,
99+
})
100+
101+
const Wrapper = () => {
102+
useCustomBreakpoint({ sm: 'up', md: 'up' })
103+
return null
104+
}
105+
mount(<Wrapper />).unmount()
106+
107+
expect(matchMediaSpy.mock.calls[0][0]).toEqual('(min-width: 400px)')
108+
})
109+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
import React, { useEffect } from 'react'
6+
7+
import { renderToString } from 'react-dom/server'
8+
import useIsomorphicEffect from '../src/useIsomorphicEffect'
9+
10+
describe('useIsomorphicEffect (ssr)', () => {
11+
it('should not run or warn', () => {
12+
let spy = jest.fn()
13+
14+
expect(useIsomorphicEffect).toEqual(useEffect)
15+
16+
const Wrapper = () => {
17+
useIsomorphicEffect(spy)
18+
return null
19+
}
20+
21+
renderToString(<Wrapper />)
22+
expect(spy).not.toBeCalled()
23+
})
24+
})

test/useIsomorphicEffect.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import React, { useLayoutEffect } from 'react'
2+
3+
import { mount } from 'enzyme'
4+
import useIsomorphicEffect from '../src/useIsomorphicEffect'
5+
6+
describe('useIsomorphicEffect', () => {
7+
it('should not run or warn', () => {
8+
let spy = jest.fn()
9+
10+
expect(useIsomorphicEffect).toEqual(useLayoutEffect)
11+
12+
const Wrapper = () => {
13+
useIsomorphicEffect(spy)
14+
return null
15+
}
16+
17+
mount(<Wrapper />)
18+
expect(spy).toBeCalled()
19+
})
20+
})

0 commit comments

Comments
 (0)