Repository: mui/material-ui (TypeScript)
Date: 2019-06-12 22:36:10+00:00
Task type: Bug Fix

Issue:
Regression: withWidth fails on the server side because of useMediaQuery
<!--- Provide a general summary of the issue in the Title above -->
Since #15678, the `withWidth()` HOC fails on the server-side (in unit tests run with react-testing-library) with the following error:

> TypeError: window.matchMedia is not a function

<!--
    Thank you very much for contributing to Material-UI by creating an issue! ❤️
    To avoid duplicate issues we ask you to check off the following list.
-->

<!-- Checked checkbox should look like this: [x] -->
- [x] This is not a v0.x issue. <!-- (v0.x is no longer maintained) -->
- [x] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Expected Behavior 🤔

No regression in a bugfix release, unchanged `withWidth()` behavior on the server side since 4.0.0

## Current Behavior 😯

Because it now uses the `useMediaQuery` hook, `withWidth()` fails on the server side.

I know I could ask `ssr: false` somehow but I prefer not to deal with that (just like `withWidth` used to work) 

<!---
    Describe what happens instead of the expected behavior.
-->

## Steps to Reproduce 🕹

Sorry, I can't provide a link to a CodeSandbox as it only happens in the server side.

Just render a component decorated with withWidth() using react-testing-library to get the error.

## Context 🔦

I'm migrating react-admin to material-ui v4 and its hooks.

<!---
    What are you trying to accomplish? How has this issue affected you?
    Providing context helps us come up with a solution that is most useful in the real world.
-->

## Your Environment 🌎
<!---
    Include as many relevant details about the environment with which you experienced the bug.
    If you encounter issues with typescript please include version and tsconfig.
-->

| Tech         | Version |
|--------------|---------|
| Material-UI  | v4.0.0-beta2  |
| React        | 16.8.0        |
| Browser      |  Chrome       |
| TypeScript   |  No       |




Issue discussion:
@fzaninotto If you are mounting the components, you need the browser API to be available. It seems that JSDOM doesn't implement it yet: https://github.com/jsdom/jsdom/blob/94774014e2244730f24d1fcb8c40d8fee3ea6b75/test/web-platform-tests/to-upstream/html/browsers/the-window-object/window-properties-dont-upstream.html#L105.

I'm wondering what we should do about it.
I would propose to add a defensive check in the core, it's the least worse solution I can think of. Would it work for you?
> If you are mounting the components, you need the browser API to be available

I didn't need it with the 4.0.0 implementation of `withWidth`. Why add such a requirement in a bugfix release? This is a BC break.
> I would propose to add a defensive check in the core, it's the least worse solution I can think of. Would it work for you?

As long as it behaves like the previous implementation, all right for me!
Ok, I think that we can move forward with that. Thanks for raising.
If I can add just one more request: can you add the defensive check at the useMediaquery level, so that the migration from withWidth is natural?
💯 
If you're using Jest and want to mock it, the following works (at least for my use case):

```js
window.matchMedia = jest.fn(() => ({
  matches: true,
  media: '',
  addListener: jest.fn(),
  removeListener: jest.fn()
}));
```

I don't know how this changes things internally, but seems fine for a temporary hack to get tests passing...

Solution patch:
diff --git a/docs/src/modules/components/AppTableOfContents.js b/docs/src/modules/components/AppTableOfContents.js
--- a/docs/src/modules/components/AppTableOfContents.js
+++ b/docs/src/modules/components/AppTableOfContents.js
@@ -122,7 +122,7 @@ function useThrottledOnScroll(callback, delay) {
 
   React.useEffect(() => {
     if (throttledCallback === noop) {
-      return () => {};
+      return undefined;
     }
 
     window.addEventListener('scroll', throttledCallback);
diff --git a/packages/material-ui/src/useMediaQuery/useMediaQuery.js b/packages/material-ui/src/useMediaQuery/useMediaQuery.js
--- a/packages/material-ui/src/useMediaQuery/useMediaQuery.js
+++ b/packages/material-ui/src/useMediaQuery/useMediaQuery.js
@@ -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) {
@@ -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);


Test patch:
diff --git a/packages/material-ui/src/useMediaQuery/useMediaQuery.test.js b/packages/material-ui/src/useMediaQuery/useMediaQuery.test.js
--- a/packages/material-ui/src/useMediaQuery/useMediaQuery.test.js
+++ b/packages/material-ui/src/useMediaQuery/useMediaQuery.test.js
@@ -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 = () => {
@@ -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);
   });
 });