Skip to content

Commit 2e6497a

Browse files
authored
Merge pull request #123 from edorivai/backward-compat-query
[WIP] Backward compatibility for the query prop
2 parents 25981eb + 1179207 commit 2e6497a

File tree

4 files changed

+378
-109
lines changed

4 files changed

+378
-109
lines changed

modules/Media.js

Lines changed: 106 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import React from 'react';
2-
import PropTypes from 'prop-types';
3-
import invariant from 'invariant';
4-
import json2mq from 'json2mq';
1+
import React from "react";
2+
import PropTypes from "prop-types";
3+
import invariant from "invariant";
4+
import json2mq from "json2mq";
55

6-
import MediaQueryList from './MediaQueryList';
6+
import MediaQueryListener from "./MediaQueryListener";
77

88
const queryType = PropTypes.oneOfType([
99
PropTypes.string,
@@ -16,8 +16,12 @@ const queryType = PropTypes.oneOfType([
1616
*/
1717
class Media extends React.Component {
1818
static propTypes = {
19-
defaultMatches: PropTypes.objectOf(PropTypes.bool),
20-
queries: PropTypes.objectOf(queryType).isRequired,
19+
defaultMatches: PropTypes.oneOfType([
20+
PropTypes.bool,
21+
PropTypes.objectOf(PropTypes.bool)
22+
]),
23+
query: queryType,
24+
queries: PropTypes.objectOf(queryType),
2125
render: PropTypes.func,
2226
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
2327
targetWindow: PropTypes.object,
@@ -29,63 +33,101 @@ class Media extends React.Component {
2933
constructor(props) {
3034
super(props);
3135

36+
invariant(
37+
!(!props.query && !props.queries) || (props.query && props.queries),
38+
'<Media> must be supplied with either "query" or "queries"'
39+
);
40+
41+
invariant(
42+
props.defaultMatches === undefined ||
43+
!props.query ||
44+
typeof props.defaultMatches === "boolean",
45+
"<Media> when query is set, defaultMatches must be a boolean, received " +
46+
typeof props.defaultMatches
47+
);
48+
49+
invariant(
50+
props.defaultMatches === undefined ||
51+
!props.queries ||
52+
typeof props.defaultMatches === "object",
53+
"<Media> when queries is set, defaultMatches must be a object of booleans, received " +
54+
typeof props.defaultMatches
55+
);
56+
3257
if (typeof window !== "object") {
33-
// In case we're rendering on the server
58+
// In case we're rendering on the server, apply the default matches
59+
let matches;
60+
if (props.defaultMatches !== undefined) {
61+
matches = props.defaultMatches;
62+
} else if (props.query) {
63+
matches = true;
64+
} /* if (props.queries) */ else {
65+
matches = Object.keys(this.props.queries).reduce(
66+
(acc, key) => ({ ...acc, [key]: true }),
67+
{}
68+
);
69+
}
3470
this.state = {
35-
matches:
36-
this.props.defaultMatches ||
37-
Object.keys(this.props.queries).reduce(
38-
(acc, key) => ({ ...acc, [key]: true }),
39-
{}
40-
)
71+
matches
4172
};
4273
return;
4374
}
4475

4576
this.initialize();
4677

47-
// Instead of calling this.updateMatches, we manually set the state to prevent
78+
// Instead of calling this.updateMatches, we manually set the initial state to prevent
4879
// calling setState, which could trigger an unnecessary second render
4980
this.state = {
5081
matches:
5182
this.props.defaultMatches !== undefined
5283
? this.props.defaultMatches
5384
: this.getMatches()
5485
};
86+
5587
this.onChange();
5688
}
5789

5890
getMatches = () => {
59-
return this.queries.reduce(
60-
(acc, { name, mqList }) => ({ ...acc, [name]: mqList.matches }),
91+
const result = this.queries.reduce(
92+
(acc, { name, mqListener }) => ({ ...acc, [name]: mqListener.matches }),
6193
{}
6294
);
95+
96+
// return result;
97+
return unwrapSingleQuery(result);
6398
};
6499

65100
updateMatches = () => {
66101
const newMatches = this.getMatches();
67102

68-
this.setState(() => ({
69-
matches: newMatches
70-
}), this.onChange);
103+
this.setState(
104+
() => ({
105+
matches: newMatches
106+
}),
107+
this.onChange
108+
);
71109
};
72110

73111
initialize() {
74112
const targetWindow = this.props.targetWindow || window;
75113

76114
invariant(
77-
typeof targetWindow.matchMedia === 'function',
78-
'<Media targetWindow> does not support `matchMedia`.'
115+
typeof targetWindow.matchMedia === "function",
116+
"<Media targetWindow> does not support `matchMedia`."
79117
);
80118

81-
const { queries } = this.props;
119+
const queries = this.props.queries || wrapInQueryObject(this.props.query);
82120

83121
this.queries = Object.keys(queries).map(name => {
84122
const query = queries[name];
85123
const qs = typeof query !== "string" ? json2mq(query) : query;
86-
const mqList = new MediaQueryList(targetWindow, qs, this.updateMatches);
124+
const mqListener = new MediaQueryListener(
125+
targetWindow,
126+
qs,
127+
this.updateMatches
128+
);
87129

88-
return { name, mqList };
130+
return { name, mqListener };
89131
});
90132
}
91133

@@ -107,35 +149,58 @@ class Media extends React.Component {
107149
}
108150

109151
componentWillUnmount() {
110-
this.queries.forEach(({ mqList }) => mqList.cancel());
152+
this.queries.forEach(({ mqListener }) => mqListener.cancel());
111153
}
112154

113155
render() {
114156
const { children, render } = this.props;
115157
const { matches } = this.state;
116158

117-
const isAnyMatches = Object.keys(matches).some(key => matches[key]);
159+
const isAnyMatches =
160+
typeof matches === "object"
161+
? Object.keys(matches).some(key => matches[key])
162+
: matches;
118163

119164
return render
120165
? isAnyMatches
121166
? render(matches)
122167
: null
123168
: children
124-
? typeof children === 'function'
125-
? children(matches)
126-
: // Preact defaults to empty children array
127-
!Array.isArray(children) || children.length
128-
? isAnyMatches
129-
? // We have to check whether child is a composite component or not to decide should we
130-
// provide `matches` as a prop or not
131-
React.Children.only(children) &&
132-
typeof React.Children.only(children).type === "string"
133-
? React.Children.only(children)
134-
: React.cloneElement(React.Children.only(children), { matches })
135-
: null
136-
: null
137-
: null;
169+
? typeof children === "function"
170+
? children(matches)
171+
: !Array.isArray(children) || children.length // Preact defaults to empty children array
172+
? isAnyMatches
173+
? // We have to check whether child is a composite component or not to decide should we
174+
// provide `matches` as a prop or not
175+
React.Children.only(children) &&
176+
typeof React.Children.only(children).type === "string"
177+
? React.Children.only(children)
178+
: React.cloneElement(React.Children.only(children), { matches })
179+
: null
180+
: null
181+
: null;
182+
}
183+
}
184+
185+
/**
186+
* Wraps a single query in an object. This is used to provide backward compatibility with
187+
* the old `query` prop (as opposed to `queries`). If only a single query is passed, the object
188+
* will be unpacked down the line, but this allows our internals to assume an object of queries
189+
* at all times.
190+
*/
191+
function wrapInQueryObject(query) {
192+
return { __DEFAULT__: query };
193+
}
194+
195+
/**
196+
* Unwraps an object of queries, if it was originally passed as a single query.
197+
*/
198+
function unwrapSingleQuery(queryObject) {
199+
const queryNames = Object.keys(queryObject);
200+
if (queryNames.length === 1 && queryNames[0] === "__DEFAULT__") {
201+
return queryObject.__DEFAULT__;
138202
}
203+
return queryObject;
139204
}
140205

141206
export default Media;

modules/MediaQueryList.js renamed to modules/MediaQueryListener.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export default class MediaQueryList {
1+
export default class MediaQueryListener {
22
constructor(targetWindow, query, listener) {
33
this.nativeMediaQueryList = targetWindow.matchMedia(query);
44
this.active = true;

modules/__tests__/Media-ssr-test.js

Lines changed: 91 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,104 @@
33
import React from "react";
44
import Media from "../Media";
55

6-
import { serverRenderStrict } from './utils';
6+
import { serverRenderStrict } from "./utils";
77

88
describe("A <Media> in server environment", () => {
9-
const queries = {
10-
sm: "(max-width: 1000px)",
11-
lg: "(max-width: 2000px)",
12-
xl: "(max-width: 3000px)"
13-
};
14-
15-
describe("when no default matches prop provided", () => {
16-
it("should render its children as if all queries are matching", () => {
17-
const element = (
18-
<Media queries={queries}>
19-
{matches => (
20-
(matches.sm && matches.lg && matches.xl)
21-
? <span>All matches, render!</span>
22-
: null
23-
)}
24-
</Media>
25-
);
26-
27-
const result = serverRenderStrict(element);
28-
29-
expect(result).toBe("<span>All matches, render!</span>");
9+
describe("and a single query is defined", () => {
10+
const query = "(max-width: 1000px)";
11+
12+
describe("when no default matches prop provided", () => {
13+
it("should render its children as if the query matches", () => {
14+
const element = (
15+
<Media query={query}>
16+
{matches =>
17+
matches === true ? <span>Matches, render!</span> : null
18+
}
19+
</Media>
20+
);
21+
22+
const result = serverRenderStrict(element);
23+
24+
expect(result).toBe("<span>Matches, render!</span>");
25+
});
26+
});
27+
28+
describe("when default matches prop provided", () => {
29+
it("should render its children according to the provided defaultMatches", () => {
30+
const render = matches => (matches === true ? <span>matches</span> : null);
31+
32+
const matched = (
33+
<Media query={query} defaultMatches={true}>
34+
{render}
35+
</Media>
36+
);
37+
38+
const matchedResult = serverRenderStrict(matched);
39+
40+
expect(matchedResult).toBe("<span>matches</span>");
41+
42+
const notMatched = (
43+
<Media query={query} defaultMatches={false}>
44+
{render}
45+
</Media>
46+
);
47+
48+
const notMatchedResult = serverRenderStrict(notMatched);
49+
50+
expect(notMatchedResult).toBe("");
51+
});
3052
});
3153
});
3254

33-
describe("when default matches prop provided", () => {
34-
const defaultMatches = {
35-
sm: true,
36-
lg: false,
37-
xl: false
55+
describe("and multiple queries are defined", () => {
56+
const queries = {
57+
sm: "(max-width: 1000px)",
58+
lg: "(max-width: 2000px)",
59+
xl: "(max-width: 3000px)"
3860
};
3961

40-
it("should render its children according to the provided defaultMatches", () => {
41-
const element = (
42-
<Media queries={queries} defaultMatches={defaultMatches}>
43-
{matches => (
44-
<div>
45-
{matches.sm && <span>small</span>}
46-
{matches.lg && <span>large</span>}
47-
{matches.xl && <span>extra large</span>}
48-
</div>
49-
)}
50-
</Media>
51-
);
52-
53-
const result = serverRenderStrict(element);
54-
55-
expect(result).toBe("<div><span>small</span></div>");
62+
describe("when no default matches prop provided", () => {
63+
it("should render its children as if all queries are matching", () => {
64+
const element = (
65+
<Media queries={queries}>
66+
{matches =>
67+
matches.sm && matches.lg && matches.xl ? (
68+
<span>All matches, render!</span>
69+
) : null
70+
}
71+
</Media>
72+
);
73+
74+
const result = serverRenderStrict(element);
75+
76+
expect(result).toBe("<span>All matches, render!</span>");
77+
});
78+
});
79+
80+
describe("when default matches prop provided", () => {
81+
const defaultMatches = {
82+
sm: true,
83+
lg: false,
84+
xl: false
85+
};
86+
87+
it("should render its children according to the provided defaultMatches", () => {
88+
const element = (
89+
<Media queries={queries} defaultMatches={defaultMatches}>
90+
{matches => (
91+
<div>
92+
{matches.sm && <span>small</span>}
93+
{matches.lg && <span>large</span>}
94+
{matches.xl && <span>extra large</span>}
95+
</div>
96+
)}
97+
</Media>
98+
);
99+
100+
const result = serverRenderStrict(element);
101+
102+
expect(result).toBe("<div><span>small</span></div>");
103+
});
56104
});
57105
});
58106
});

0 commit comments

Comments
 (0)