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

[useMediaQuery] Defensive logic against matchMedia not available #16196

Merged
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
2 changes: 1 addition & 1 deletion docs/src/modules/components/AppTableOfContents.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ function useThrottledOnScroll(callback, delay) {

React.useEffect(() => {
if (throttledCallback === noop) {
return () => {};
return undefined;
}

window.addEventListener('scroll', throttledCallback);
Expand Down
13 changes: 12 additions & 1 deletion packages/material-ui/src/useMediaQuery/useMediaQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@ function useMediaQuery(queryInput, options = {}) {
let queries = multiple ? queryInput : [queryInput];
queries = queries.map(query => query.replace('@media ', ''));

// Wait for JSDOM to support the match media feature.
// All the browsers Material-UI support have this built-in.
// This defensive check is here for simplicity.
// Most of the time, the match media logic isn't central to people tests.
const supportMatchMedia =
typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined';

const { defaultMatches = false, noSsr = false, ssrMatchMedia = null } = options;

const [matches, setMatches] = React.useState(() => {
if (hydrationCompleted || noSsr) {
if ((hydrationCompleted || noSsr) && supportMatchMedia) {
return queries.map(query => window.matchMedia(query).matches);
}
if (ssrMatchMedia) {
Expand All @@ -30,6 +37,10 @@ function useMediaQuery(queryInput, options = {}) {
React.useEffect(() => {
hydrationCompleted = true;

if (!supportMatchMedia) {
return undefined;
}

const queryLists = queries.map(query => window.matchMedia(query));
setMatches(prev => {
const next = queryLists.map(queryList => queryList.matches);
Expand Down
227 changes: 122 additions & 105 deletions packages/material-ui/src/useMediaQuery/useMediaQuery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,33 +48,117 @@ describe('useMediaQuery', () => {
mount = createMount({ strict: true });
});

beforeEach(() => {
testReset();
values = spy();
matchMediaInstances = [];
window.matchMedia = createMatchMedia(1200, matchMediaInstances);
});

after(() => {
mount.cleanUp();
});

describe('option: defaultMatches', () => {
it('should be false by default', () => {
describe('without feature', () => {
it('should work without window.matchMedia available', () => {
assert.strictEqual(typeof window.matchMedia, 'undefined');
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)');
React.useEffect(() => values(matches));
const matches = useMediaQuery('(min-width:100px)');
return <span ref={ref}>{`${matches}`}</span>;
};

mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 1);
});
});

describe('with feature', () => {
beforeEach(() => {
testReset();
values = spy();
matchMediaInstances = [];
window.matchMedia = createMatchMedia(1200, matchMediaInstances);
});

describe('option: defaultMatches', () => {
it('should be false by default', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)');
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};

mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 1);
});

it('should take the option into account', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)', {
defaultMatches: true,
});
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};

mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 2);
});
});

describe('option: noSsr', () => {
it('should render once if the default value match the expectation', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)', {
defaultMatches: false,
});
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};

mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 1);
});

it('should render twice if the default value does not match the expectation', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)', {
defaultMatches: true,
});
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};

mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 2);
});

it('should take the option into account', () => {
it('should render once if the default value does not match the expectation', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)', {
defaultMatches: true,
noSsr: true,
});
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};

mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 1);
});
});

it('should try to reconcile only the first time', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
Expand All @@ -88,125 +172,58 @@ describe('useMediaQuery', () => {
mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 2);
});
});

describe('option: noSsr', () => {
it('should render once if the default value match the expectation', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)', {
defaultMatches: false,
});
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};
ReactDOM.unmountComponentAtNode(mount.attachTo);

mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 1);
assert.strictEqual(values.callCount, 3);
});

it('should render twice if the default value does not match the expectation', () => {
it('should be able to change the query dynamically', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)', {
const Test = props => {
const matches = useMediaQuery(props.query, {
defaultMatches: true,
});
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};
Test.propTypes = {
query: PropTypes.string.isRequired,
};

mount(<Test />);
const wrapper = mount(<Test query="(min-width:2000px)" />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 2);
wrapper.setProps({ query: '(min-width:100px)' });
assert.strictEqual(text(), 'true');
assert.strictEqual(values.callCount, 4);
});

it('should render once if the default value does not match the expectation', () => {
it('should observe the media query', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)', {
defaultMatches: true,
noSsr: true,
});
const Test = props => {
const matches = useMediaQuery(props.query);
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};
Test.propTypes = {
query: PropTypes.string.isRequired,
};

mount(<Test />);
assert.strictEqual(text(), 'false');
mount(<Test query="(min-width:2000px)" />);
assert.strictEqual(values.callCount, 1);
});
});

it('should try to reconcile only the first time', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = () => {
const matches = useMediaQuery('(min-width:2000px)', {
defaultMatches: true,
});
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};

mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 2);

ReactDOM.unmountComponentAtNode(mount.attachTo);

mount(<Test />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 3);
});
assert.strictEqual(text(), 'false');

it('should be able to change the query dynamically', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = props => {
const matches = useMediaQuery(props.query, {
defaultMatches: true,
act(() => {
matchMediaInstances[0].instance.matches = true;
matchMediaInstances[0].listeners[0]();
});
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};
Test.propTypes = {
query: PropTypes.string.isRequired,
};

const wrapper = mount(<Test query="(min-width:2000px)" />);
assert.strictEqual(text(), 'false');
assert.strictEqual(values.callCount, 2);
wrapper.setProps({ query: '(min-width:100px)' });
assert.strictEqual(text(), 'true');
assert.strictEqual(values.callCount, 4);
});

it('should observe the media query', () => {
const ref = React.createRef();
const text = () => ref.current.textContent;
const Test = props => {
const matches = useMediaQuery(props.query);
React.useEffect(() => values(matches));
return <span ref={ref}>{`${matches}`}</span>;
};
Test.propTypes = {
query: PropTypes.string.isRequired,
};

mount(<Test query="(min-width:2000px)" />);
assert.strictEqual(values.callCount, 1);
assert.strictEqual(text(), 'false');

act(() => {
matchMediaInstances[0].instance.matches = true;
matchMediaInstances[0].listeners[0]();
assert.strictEqual(text(), 'true');
assert.strictEqual(values.callCount, 2);
});
assert.strictEqual(text(), 'true');
assert.strictEqual(values.callCount, 2);
});
});