Repository: streamich/react-use
Pull request #1213 (2020-05-16 12:56:09)

Improve use measure
# Description

Improvements to `useMeasure` hook.



Patch:
diff --git a/docs/useMeasure.md b/docs/useMeasure.md
index d8e87a0c..306fec3b 100644
--- a/docs/useMeasure.md
+++ b/docs/useMeasure.md
@@ -25,6 +25,21 @@ const Demo = () => {
 };
 ```
 
+This hook uses [`ResizeObserver` API][resize-observer], if you want to support 
+legacy browsers, consider installing [`resize-observer-polyfill`][resize-observer-polyfill] 
+before running your app. 
+
+```js
+if (!window.ResizeObserver) {
+  window.ResizeObserver = (await import('resize-observer-polyfill')).default;
+}
+```
+
+
 ## Related hooks
 
 - [useSize](./useSize.md)
+
+
+[resize-observer]: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
+[resize-observer-polyfill]: https://www.npmjs.com/package/resize-observer-polyfill
diff --git a/src/useMeasure.ts b/src/useMeasure.ts
index 4622355f..a5bee4aa 100644
--- a/src/useMeasure.ts
+++ b/src/useMeasure.ts
@@ -1,40 +1,51 @@
-import { useCallback, useState } from 'react';
-import ResizeObserver from 'resize-observer-polyfill';
-
-export type ContentRect = Pick<DOMRectReadOnly, 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'>;
-
-const useMeasure = <T>(): [(instance: T) => void, ContentRect] => {
-  const [rect, set] = useState<ContentRect>({
-    x: 0,
-    y: 0,
-    width: 0,
-    height: 0,
-    top: 0,
-    left: 0,
-    bottom: 0,
-    right: 0,
-  });
-
-  const [observer] = useState(
+import { useState, useMemo } from 'react';
+import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect';
+import { isClient } from './util';
+
+export type UseMeasureRect = Pick<
+  DOMRectReadOnly,
+  'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'
+>;
+export type UseMeasureRef = (element: HTMLElement) => void;
+export type UseMeasureResult = [UseMeasureRef, UseMeasureRect];
+
+const defaultState: UseMeasureRect = {
+  x: 0,
+  y: 0,
+  width: 0,
+  height: 0,
+  top: 0,
+  left: 0,
+  bottom: 0,
+  right: 0,
+};
+
+const useMeasure = (): UseMeasureResult => {
+  const [element, ref] = useState<HTMLElement | null>(null);
+  const [rect, setRect] = useState<UseMeasureRect>(defaultState);
+
+  const observer = useMemo(
     () =>
-      new ResizeObserver(entries => {
-        const entry = entries[0];
-        if (entry) {
-          set(entry.contentRect);
+      new (window as any).ResizeObserver(entries => {
+        if (entries[0]) {
+          const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect;
+          setRect({ x, y, width, height, top, left, bottom, right });
         }
-      })
+      }),
+    []
   );
 
-  const ref = useCallback(
-    node => {
+  useIsomorphicLayoutEffect(() => {
+    if (!element) return;
+    observer.observe(element);
+    return () => {
       observer.disconnect();
-      if (node) {
-        observer.observe(node);
-      }
-    },
-    [observer]
-  );
+    };
+  }, [element]);
+
   return [ref, rect];
 };
 
-export default useMeasure;
+const useMeasureMock = () => [() => {}, defaultState];
+
+export default (isClient && !!(window as any).ResizeObserver) ? useMeasure : useMeasureMock;


Test patch:
diff --git a/tests/setupTests.ts b/tests/setupTests.ts
index 1c787210..c0ced93c 100644
--- a/tests/setupTests.ts
+++ b/tests/setupTests.ts
@@ -1,1 +1,6 @@
 import 'jest-localstorage-mock';
+
+(window as any).ResizeObserver = class ResizeObserver {
+  observe() {}
+  disconnect() {}
+};
diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts
index a242865b..94c83b6e 100644
--- a/tests/useMeasure.test.ts
+++ b/tests/useMeasure.test.ts
@@ -1,78 +1,14 @@
-/* eslint-disable */
-import { act, renderHook } from '@testing-library/react-hooks';
-import useMeasure, { ContentRect } from '../src/useMeasure';
-
-interface Entry {
-  target: HTMLElement;
-  contentRect: ContentRect;
-}
-
-jest.mock('resize-observer-polyfill', () => {
-  return class ResizeObserver {
-    private cb: (entries: Entry[]) => void;
-    private map: WeakMap<HTMLElement, any>;
-    private targets: HTMLElement[];
-    constructor(cb: () => void) {
-      this.cb = cb;
-      this.map = new WeakMap();
-      this.targets = [];
-    }
-    public disconnect() {
-      this.targets.map(target => {
-        const originMethod = this.map.get(target);
-        target.setAttribute = originMethod;
-        this.map.delete(target);
-      });
-    }
-    public observe(target: HTMLElement) {
-      const method = 'setAttribute';
-      const originMethod = target[method];
-      this.map.set(target, originMethod);
-      this.targets.push(target);
-      target[method] = (...args) => {
-        const [attrName, value] = args;
-        if (attrName === 'style') {
-          const rect: DOMRectReadOnly = {
-            x: 0,
-            y: 0,
-            top: 0,
-            left: 0,
-            right: 0,
-            bottom: 0,
-            width: 0,
-            height: 0,
-          } as DOMRectReadOnly;
-          value.split(';').map(kv => {
-            const [key, v] = kv.split(':');
-            if (['top', 'bottom', 'left', 'right', 'width', 'height'].includes(key)) {
-              rect[key] = parseInt(v, 10);
-            }
-          });
-          target.getBoundingClientRect = () => rect;
-        }
-        originMethod.apply(target, args);
-        this.fireCallback();
-      };
-    }
-    private fireCallback() {
-      if (this.cb) {
-        this.cb(
-          this.targets.map(target => {
-            return {
-              target,
-              contentRect: target.getBoundingClientRect() as ContentRect,
-            };
-          })
-        );
-      }
-    }
-  };
-});
+import { renderHook, act } from '@testing-library/react-hooks';
+import useMeasure, { UseMeasureRef } from '../src/useMeasure';
 
-it('reacts to changes in size of any of the observed elements', () => {
+it('by default, state defaults every value to -1', () => {
   const { result } = renderHook(() => useMeasure());
-  const div = document.createElement('div');
-  result.current[0](div);
+
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+
   expect(result.current[1]).toMatchObject({
     width: 0,
     height: 0,
@@ -81,9 +17,63 @@ it('reacts to changes in size of any of the observed elements', () => {
     left: 0,
     right: 0,
   });
-  act(() => div.setAttribute('style', 'width:200px;height:200px;top:100;left:100'));
+});
+
+it('synchronously sets up ResizeObserver listener', () => {
+  let listener: ((rect: any) => void) | undefined = undefined;
+  (window as any).ResizeObserver = class ResizeObserver {
+    constructor(ls) {
+      listener = ls;
+    }
+    observe() {}
+    disconnect() {}
+  };
+
+  const { result } = renderHook(() => useMeasure());
+  
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+
+  expect(typeof listener).toBe('function');
+});
+
+it('tracks rectangle of a DOM element', () => {
+  let listener: ((rect: any) => void) | undefined = undefined;
+  (window as any).ResizeObserver = class ResizeObserver {
+    constructor(ls) {
+      listener = ls;
+    }
+    observe() {}
+    disconnect() {}
+  };
+
+  const { result } = renderHook(() => useMeasure());
+  
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+  
+  act(() => {
+    listener!([{
+      contentRect: {
+        x: 1,
+        y: 2,
+        width: 200,
+        height: 200,
+        top: 100,
+        bottom: 0,
+        left: 100,
+        right: 0,
+      }
+    }]);
+  });
 
   expect(result.current[1]).toMatchObject({
+    x: 1,
+    y: 2,
     width: 200,
     height: 200,
     top: 100,
@@ -92,3 +82,96 @@ it('reacts to changes in size of any of the observed elements', () => {
     right: 0,
   });
 });
+
+it('tracks multiple updates', () => {
+  let listener: ((rect: any) => void) | undefined = undefined;
+  (window as any).ResizeObserver = class ResizeObserver {
+    constructor(ls) {
+      listener = ls;
+    }
+    observe() {}
+    disconnect() {}
+  };
+
+  const { result } = renderHook(() => useMeasure());
+  
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+  
+  act(() => {
+    listener!([{
+      contentRect: {
+        x: 1,
+        y: 1,
+        width: 1,
+        height: 1,
+        top: 1,
+        bottom: 1,
+        left: 1,
+        right: 1,
+      }
+    }]);
+  });
+
+  expect(result.current[1]).toMatchObject({
+    x: 1,
+    y: 1,
+    width: 1,
+    height: 1,
+    top: 1,
+    bottom: 1,
+    left: 1,
+    right: 1,
+  });
+  
+  act(() => {
+    listener!([{
+      contentRect: {
+        x: 2,
+        y: 2,
+        width: 2,
+        height: 2,
+        top: 2,
+        bottom: 2,
+        left: 2,
+        right: 2,
+      }
+    }]);
+  });
+
+  expect(result.current[1]).toMatchObject({
+    x: 2,
+    y: 2,
+    width: 2,
+    height: 2,
+    top: 2,
+    bottom: 2,
+    left: 2,
+    right: 2,
+  });
+});
+
+it('calls .disconnect() on ResizeObserver when component unmounts', () => {
+  const disconnect = jest.fn();
+  (window as any).ResizeObserver = class ResizeObserver {
+    observe() {}
+    disconnect() {
+      disconnect();
+    }
+  };
+
+  const { result, unmount } = renderHook(() => useMeasure());
+  
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+
+  expect(disconnect).toHaveBeenCalledTimes(0);
+
+  unmount();
+
+  expect(disconnect).toHaveBeenCalledTimes(1);
+});