Skip to content
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

[WIP] Backward compatibility for the query prop #123

Merged
merged 2 commits into from
Mar 25, 2019
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
147 changes: 106 additions & 41 deletions modules/Media.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import invariant from 'invariant';
import json2mq from 'json2mq';
import React from "react";
import PropTypes from "prop-types";
import invariant from "invariant";
import json2mq from "json2mq";

import MediaQueryList from './MediaQueryList';
import MediaQueryListener from "./MediaQueryListener";

const queryType = PropTypes.oneOfType([
PropTypes.string,
Expand All @@ -16,8 +16,12 @@ const queryType = PropTypes.oneOfType([
*/
class Media extends React.Component {
static propTypes = {
defaultMatches: PropTypes.objectOf(PropTypes.bool),
queries: PropTypes.objectOf(queryType).isRequired,
defaultMatches: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.bool)
]),
query: queryType,
queries: PropTypes.objectOf(queryType),
render: PropTypes.func,
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
targetWindow: PropTypes.object,
Expand All @@ -29,63 +33,101 @@ class Media extends React.Component {
constructor(props) {
super(props);

invariant(
!(!props.query && !props.queries) || (props.query && props.queries),
'<Media> must be supplied with either "query" or "queries"'
);

invariant(
props.defaultMatches === undefined ||
!props.query ||
typeof props.defaultMatches === "boolean",
"<Media> when query is set, defaultMatches must be a boolean, received " +
typeof props.defaultMatches
);

invariant(
props.defaultMatches === undefined ||
!props.queries ||
typeof props.defaultMatches === "object",
"<Media> when queries is set, defaultMatches must be a object of booleans, received " +
typeof props.defaultMatches
);

if (typeof window !== "object") {
// In case we're rendering on the server
// In case we're rendering on the server, apply the default matches
let matches;
if (props.defaultMatches !== undefined) {
matches = props.defaultMatches;
} else if (props.query) {
matches = true;
} /* if (props.queries) */ else {
matches = Object.keys(this.props.queries).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
);
}
this.state = {
matches:
this.props.defaultMatches ||
Object.keys(this.props.queries).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
)
matches
};
return;
}

this.initialize();

// Instead of calling this.updateMatches, we manually set the state to prevent
// Instead of calling this.updateMatches, we manually set the initial state to prevent
// calling setState, which could trigger an unnecessary second render
this.state = {
matches:
this.props.defaultMatches !== undefined
? this.props.defaultMatches
: this.getMatches()
};

this.onChange();
}

getMatches = () => {
return this.queries.reduce(
(acc, { name, mqList }) => ({ ...acc, [name]: mqList.matches }),
const result = this.queries.reduce(
(acc, { name, mqListener }) => ({ ...acc, [name]: mqListener.matches }),
{}
);

// return result;
return unwrapSingleQuery(result);
};

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

this.setState(() => ({
matches: newMatches
}), this.onChange);
this.setState(
() => ({
matches: newMatches
}),
this.onChange
);
};

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

invariant(
typeof targetWindow.matchMedia === 'function',
'<Media targetWindow> does not support `matchMedia`.'
typeof targetWindow.matchMedia === "function",
"<Media targetWindow> does not support `matchMedia`."
);

const { queries } = this.props;
const queries = this.props.queries || wrapInQueryObject(this.props.query);

this.queries = Object.keys(queries).map(name => {
const query = queries[name];
const qs = typeof query !== "string" ? json2mq(query) : query;
const mqList = new MediaQueryList(targetWindow, qs, this.updateMatches);
const mqListener = new MediaQueryListener(
targetWindow,
qs,
this.updateMatches
);

return { name, mqList };
return { name, mqListener };
});
}

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

componentWillUnmount() {
this.queries.forEach(({ mqList }) => mqList.cancel());
this.queries.forEach(({ mqListener }) => mqListener.cancel());
}

render() {
const { children, render } = this.props;
const { matches } = this.state;

const isAnyMatches = Object.keys(matches).some(key => matches[key]);
const isAnyMatches =
typeof matches === "object"
? Object.keys(matches).some(key => matches[key])
: matches;

return render
? isAnyMatches
? render(matches)
: null
: children
? typeof children === 'function'
? children(matches)
: // Preact defaults to empty children array
!Array.isArray(children) || children.length
? isAnyMatches
? // We have to check whether child is a composite component or not to decide should we
// provide `matches` as a prop or not
React.Children.only(children) &&
typeof React.Children.only(children).type === "string"
? React.Children.only(children)
: React.cloneElement(React.Children.only(children), { matches })
: null
: null
: null;
? typeof children === "function"
? children(matches)
: !Array.isArray(children) || children.length // Preact defaults to empty children array
? isAnyMatches
? // We have to check whether child is a composite component or not to decide should we
// provide `matches` as a prop or not
React.Children.only(children) &&
typeof React.Children.only(children).type === "string"
? React.Children.only(children)
: React.cloneElement(React.Children.only(children), { matches })
: null
: null
: null;
}
}

/**
* Wraps a single query in an object. This is used to provide backward compatibility with
* the old `query` prop (as opposed to `queries`). If only a single query is passed, the object
* will be unpacked down the line, but this allows our internals to assume an object of queries
* at all times.
*/
function wrapInQueryObject(query) {
return { __DEFAULT__: query };
}

/**
* Unwraps an object of queries, if it was originally passed as a single query.
*/
function unwrapSingleQuery(queryObject) {
const queryNames = Object.keys(queryObject);
if (queryNames.length === 1 && queryNames[0] === "__DEFAULT__") {
return queryObject.__DEFAULT__;
}
return queryObject;
}

export default Media;
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export default class MediaQueryList {
export default class MediaQueryListener {
constructor(targetWindow, query, listener) {
this.nativeMediaQueryList = targetWindow.matchMedia(query);
this.active = true;
Expand Down
134 changes: 91 additions & 43 deletions modules/__tests__/Media-ssr-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,56 +3,104 @@
import React from "react";
import Media from "../Media";

import { serverRenderStrict } from './utils';
import { serverRenderStrict } from "./utils";

describe("A <Media> in server environment", () => {
const queries = {
sm: "(max-width: 1000px)",
lg: "(max-width: 2000px)",
xl: "(max-width: 3000px)"
};

describe("when no default matches prop provided", () => {
it("should render its children as if all queries are matching", () => {
const element = (
<Media queries={queries}>
{matches => (
(matches.sm && matches.lg && matches.xl)
? <span>All matches, render!</span>
: null
)}
</Media>
);

const result = serverRenderStrict(element);

expect(result).toBe("<span>All matches, render!</span>");
describe("and a single query is defined", () => {
const query = "(max-width: 1000px)";

describe("when no default matches prop provided", () => {
it("should render its children as if the query matches", () => {
const element = (
<Media query={query}>
{matches =>
matches === true ? <span>Matches, render!</span> : null
}
</Media>
);

const result = serverRenderStrict(element);

expect(result).toBe("<span>Matches, render!</span>");
});
});

describe("when default matches prop provided", () => {
it("should render its children according to the provided defaultMatches", () => {
const render = matches => (matches === true ? <span>matches</span> : null);

const matched = (
<Media query={query} defaultMatches={true}>
{render}
</Media>
);

const matchedResult = serverRenderStrict(matched);

expect(matchedResult).toBe("<span>matches</span>");

const notMatched = (
<Media query={query} defaultMatches={false}>
{render}
</Media>
);

const notMatchedResult = serverRenderStrict(notMatched);

expect(notMatchedResult).toBe("");
});
});
});

describe("when default matches prop provided", () => {
const defaultMatches = {
sm: true,
lg: false,
xl: false
describe("and multiple queries are defined", () => {
const queries = {
sm: "(max-width: 1000px)",
lg: "(max-width: 2000px)",
xl: "(max-width: 3000px)"
};

it("should render its children according to the provided defaultMatches", () => {
const element = (
<Media queries={queries} defaultMatches={defaultMatches}>
{matches => (
<div>
{matches.sm && <span>small</span>}
{matches.lg && <span>large</span>}
{matches.xl && <span>extra large</span>}
</div>
)}
</Media>
);

const result = serverRenderStrict(element);

expect(result).toBe("<div><span>small</span></div>");
describe("when no default matches prop provided", () => {
it("should render its children as if all queries are matching", () => {
const element = (
<Media queries={queries}>
{matches =>
matches.sm && matches.lg && matches.xl ? (
<span>All matches, render!</span>
) : null
}
</Media>
);

const result = serverRenderStrict(element);

expect(result).toBe("<span>All matches, render!</span>");
});
});

describe("when default matches prop provided", () => {
const defaultMatches = {
sm: true,
lg: false,
xl: false
};

it("should render its children according to the provided defaultMatches", () => {
const element = (
<Media queries={queries} defaultMatches={defaultMatches}>
{matches => (
<div>
{matches.sm && <span>small</span>}
{matches.lg && <span>large</span>}
{matches.xl && <span>extra large</span>}
</div>
)}
</Media>
);

const result = serverRenderStrict(element);

expect(result).toBe("<div><span>small</span></div>");
});
});
});
});
Loading