Repository: streamich/react-use
Pull request #882 (2020-01-12 16:56:56)

Use measure 2


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/package.json b/package.json
index b64c6a8c..0db04b2e 100644
--- a/package.json
+++ b/package.json
@@ -157,6 +157,9 @@
     "coverageDirectory": "coverage",
     "testMatch": [
       "<rootDir>/tests/**/*.test.(ts|tsx)"
+    ],
+    "setupFiles": [
+      "<rootDir>/tests/_setup.js"
     ]
   }
 }
diff --git a/src/useMeasure.ts b/src/useMeasure.ts
index 4622355f..57d5c6d3 100644
--- a/src/useMeasure.ts
+++ b/src/useMeasure.ts
@@ -1,40 +1,50 @@
-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';
+
+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: -1,
+  y: -1,
+  width: -1,
+  height: -1,
+  top: -1,
+  left: -1,
+  bottom: -1,
+  right: -1,
+};
+
+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 !!(window as any).ResizeObserver ? useMeasure : useMeasureMock;


Test patch:
diff --git a/tests/_setup.js b/tests/_setup.js
new file mode 100644
index 00000000..b4816e40
--- /dev/null
+++ b/tests/_setup.js
@@ -0,0 +1,5 @@
+window.ResizeObserver = class ResizeObserver {
+  constructor() {}
+  observe() {}
+  disconnect() {}
+};
diff --git a/tests/useMeasure.test.ts b/tests/useMeasure.test.ts
index 6d9da805..1f74e9e3 100644
--- a/tests/useMeasure.test.ts
+++ b/tests/useMeasure.test.ts
@@ -1,88 +1,81 @@
-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('by default, state defaults every value to -1', () => {
+  const { result } = renderHook(() => useMeasure());
+
+  act(() => {
+    const div = document.createElement('div');
+    (result.current[0] as UseMeasureRef)(div);
+  });
+
+  expect(result.current[1]).toMatchObject({
+    width: -1,
+    height: -1,
+    top: -1,
+    bottom: -1,
+    left: -1,
+    right: -1,
+  });
+});
+
+it('synchronously sets up ResizeObserver listener', () => {
+  let listener: ((rect: any) => void) | 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('reacts to changes in size of any of the observed elements', () => {
+it('tracks rectangle of a DOM element', () => {
+  let listener: ((rect: any) => void) | undefined;
+  (window as any).ResizeObserver = class ResizeObserver {
+    constructor(ls) {
+      listener = ls;
+    }
+    observe() {}
+    disconnect() {}
+  };
+
   const { result } = renderHook(() => useMeasure());
-  const div = document.createElement('div');
-  result.current[0](div);
-  expect(result.current[1]).toMatchObject({
-    width: 0,
-    height: 0,
-    top: 0,
-    bottom: 0,
-    left: 0,
-    right: 0,
+
+  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,
+        },
+      },
+    ]);
   });
-  act(() => div.setAttribute('style', 'width:200px;height:200px;top:100;left:100'));
 
   expect(result.current[1]).toMatchObject({
+    x: 1,
+    y: 2,
     width: 200,
     height: 200,
     top: 100,
@@ -91,3 +84,101 @@ it('reacts to changes in size of any of the observed elements', () => {
     right: 0,
   });
 });
+
+it('tracks multiple updates', () => {
+  let listener: ((rect: any) => void) | 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 {
+    constructor() {}
+    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);
+});