diff --git a/.prettierrc b/.prettierrc index f307fb1..7f12e04 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,6 @@ "singleQuote": true, "tabWidth": 2, "trailingComma": "all", - "printWidth": 100 + "printWidth": 100, + "arrowParens": "avoid" } diff --git a/docs/demo/basic.md b/docs/demo/basic.md index 806f142..a28975d 100644 --- a/docs/demo/basic.md +++ b/docs/demo/basic.md @@ -1,3 +1,3 @@ -## basic +## Basic diff --git a/docs/demo/collection.md b/docs/demo/collection.md new file mode 100644 index 0000000..48c8503 --- /dev/null +++ b/docs/demo/collection.md @@ -0,0 +1,5 @@ +## Collection + +Use `ResizeObserver.Collection` to collect multiple `ResizeObserver` resize event within frame. + + diff --git a/examples/collection.tsx b/examples/collection.tsx new file mode 100644 index 0000000..f3ada63 --- /dev/null +++ b/examples/collection.tsx @@ -0,0 +1,57 @@ +import '../assets/index.less'; +import React from 'react'; +import ResizeObserver from '../src'; + +function randomSize() { + return { + width: Math.round(50 + Math.random() * 150), + height: Math.round(50 + Math.random() * 150), + }; +} + +const sharedStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: '#fff', +}; + +export default function App() { + const [size1, setSize1] = React.useState(randomSize()); + const [size2, setSize2] = React.useState(randomSize()); + + console.log('Render:', size1, size2); + + return ( + { + console.log( + 'Batch Resize:', + infoList, + infoList.map(({ data, size }) => `${data}(${size.width}/${size.height})`), + ); + }} + > +
+ + + +
+
+ +
1
+
+ +
2
+
+
+
+ ); +} diff --git a/package.json b/package.json index 86a554f..29c0e7f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.1", - "rc-util": "^5.0.0", + "rc-util": "^5.15.0", "resize-observer-polyfill": "^1.5.1" }, "devDependencies": { diff --git a/src/Collection.tsx b/src/Collection.tsx new file mode 100644 index 0000000..df544a0 --- /dev/null +++ b/src/Collection.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import type { SizeInfo } from '.'; + +type onCollectionResize = (size: SizeInfo, element: HTMLElement, data: any) => void; + +export const CollectionContext = React.createContext(null); + +export interface ResizeInfo { + size: SizeInfo; + data: any; + element: HTMLElement; +} + +export interface CollectionProps { + /** Trigger when some children ResizeObserver changed. Collect by frame render level */ + onBatchResize?: (resizeInfo: ResizeInfo[]) => void; + children?: React.ReactNode; +} + +/** + * Collect all the resize event from children ResizeObserver + */ +export function Collection({ children, onBatchResize }: CollectionProps) { + const resizeIdRef = React.useRef(0); + const resizeInfosRef = React.useRef([]); + + const onCollectionResize = React.useContext(CollectionContext); + + const onResize = React.useCallback( + (size, element, data) => { + resizeIdRef.current += 1; + const currentId = resizeIdRef.current; + + resizeInfosRef.current.push({ + size, + element, + data, + }); + + Promise.resolve().then(() => { + if (currentId === resizeIdRef.current) { + onBatchResize?.(resizeInfosRef.current); + resizeInfosRef.current = []; + } + }); + + // Continue bubbling if parent exist + onCollectionResize?.(size, element, data); + }, + [onBatchResize, onCollectionResize], + ); + + return {children}; +} diff --git a/src/SingleObserver/DomWrapper.tsx b/src/SingleObserver/DomWrapper.tsx new file mode 100644 index 0000000..83c7646 --- /dev/null +++ b/src/SingleObserver/DomWrapper.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; + +export interface DomWrapperProps { + children: React.ReactElement; +} + +/** + * Fallback to findDOMNode if origin ref do not provide any dom element + */ +export default class DomWrapper extends React.Component { + render() { + return this.props.children; + } +} diff --git a/src/SingleObserver/index.tsx b/src/SingleObserver/index.tsx new file mode 100644 index 0000000..7df86e8 --- /dev/null +++ b/src/SingleObserver/index.tsx @@ -0,0 +1,110 @@ +import { composeRef, supportRef } from 'rc-util/lib/ref'; +import * as React from 'react'; +import findDOMNode from 'rc-util/lib/Dom/findDOMNode'; +import { observe, unobserve } from '../utils/observerUtil'; +import type { ResizeObserverProps } from '..'; +import DomWrapper from './DomWrapper'; +import { CollectionContext } from '../Collection'; + +export interface SingleObserverProps extends ResizeObserverProps { + children: React.ReactElement; +} + +export default function SingleObserver(props: SingleObserverProps) { + const { children, disabled } = props; + const elementRef = React.useRef(null); + const wrapperRef = React.useRef(null); + + const onCollectionResize = React.useContext(CollectionContext); + + // ============================= Size ============================= + const sizeRef = React.useRef({ + width: 0, + height: 0, + offsetWidth: 0, + offsetHeight: 0, + }); + + // ============================= Ref ============================== + const canRef = React.isValidElement(children) && supportRef(children); + const originRef: React.Ref = canRef ? (children as any).ref : null; + + const mergedRef = React.useMemo( + () => composeRef(originRef, elementRef), + [originRef, elementRef], + ); + + // =========================== Observe ============================ + const propsRef = React.useRef(props); + propsRef.current = props; + + // Handler + const onInternalResize = React.useCallback((target: HTMLElement) => { + const { onResize, data } = propsRef.current; + + const { width, height } = target.getBoundingClientRect(); + const { offsetWidth, offsetHeight } = target; + + /** + * Resize observer trigger when content size changed. + * In most case we just care about element size, + * let's use `boundary` instead of `contentRect` here to avoid shaking. + */ + const fixedWidth = Math.floor(width); + const fixedHeight = Math.floor(height); + + if ( + sizeRef.current.width !== fixedWidth || + sizeRef.current.height !== fixedHeight || + sizeRef.current.offsetWidth !== offsetWidth || + sizeRef.current.offsetHeight !== offsetHeight + ) { + const size = { width: fixedWidth, height: fixedHeight, offsetWidth, offsetHeight }; + sizeRef.current = size; + + // IE is strange, right? + const mergedOffsetWidth = offsetWidth === Math.round(width) ? width : offsetWidth; + const mergedOffsetHeight = offsetHeight === Math.round(height) ? height : offsetHeight; + + const sizeInfo = { + ...size, + offsetWidth: mergedOffsetWidth, + offsetHeight: mergedOffsetHeight, + }; + + // Let collection know what happened + onCollectionResize?.(sizeInfo, target, data); + + if (onResize) { + // defer the callback but not defer to next frame + Promise.resolve().then(() => { + onResize(sizeInfo, target); + }); + } + } + }, []); + + // Dynamic observe + React.useEffect(() => { + const currentElement: HTMLElement = + findDOMNode(elementRef.current) || findDOMNode(wrapperRef.current); + + if (currentElement && !disabled) { + observe(currentElement, onInternalResize); + } + + return () => unobserve(currentElement, onInternalResize); + }, [elementRef.current, disabled]); + + // ============================ Render ============================ + if (canRef) { + return ( + + {React.cloneElement(children as any, { + ref: mergedRef, + })} + + ); + } + return children; +} diff --git a/src/index.tsx b/src/index.tsx index 8482d37..3324bc3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,181 +1,54 @@ import * as React from 'react'; -import findDOMNode from 'rc-util/lib/Dom/findDOMNode'; import toArray from 'rc-util/lib/Children/toArray'; -import warning from 'rc-util/lib/warning'; -import { composeRef, supportRef } from 'rc-util/lib/ref'; -import ResizeObserver from 'resize-observer-polyfill'; +import { warning } from 'rc-util/lib/warning'; +import SingleObserver from './SingleObserver'; +import { Collection } from './Collection'; const INTERNAL_PREFIX_KEY = 'rc-observer-key'; -export interface ResizeObserverProps { - children: React.ReactNode; - disabled?: boolean; - /** Trigger if element resized. Will always trigger when first time render. */ - onResize?: ( - size: { - width: number; - height: number; - offsetWidth: number; - offsetHeight: number; - }, - element: HTMLElement, - ) => void; -} - -interface ResizeObserverState { - height: number; +export interface SizeInfo { width: number; - offsetHeight: number; + height: number; offsetWidth: number; + offsetHeight: number; } -type RefNode = React.ReactInstance | HTMLElement | null; - -// Still need to be compatible with React 15, we use class component here -class ReactResizeObserver extends React.Component { - static displayName = 'ResizeObserver'; - - resizeObserver: ResizeObserver | null = null; - - childNode: RefNode = null; - - currentElement: Element | null = null; +export type OnResize = (size: SizeInfo, element: HTMLElement) => void; - state = { - width: 0, - height: 0, - offsetHeight: 0, - offsetWidth: 0, - }; - - componentDidMount() { - this.onComponentUpdated(); - } - - componentDidUpdate() { - this.onComponentUpdated(); - } - - componentWillUnmount() { - this.destroyObserver(); - } - - onComponentUpdated() { - const { disabled } = this.props; - - // Unregister if disabled - if (disabled) { - this.destroyObserver(); - return; - } - - // Unregister if element changed - const element = findDOMNode(this.childNode || this) as Element; - const elementChanged = element !== this.currentElement; - if (elementChanged) { - this.destroyObserver(); - this.currentElement = element; - } - - if (!this.resizeObserver && element) { - this.resizeObserver = new ResizeObserver(this.onResize); - this.resizeObserver.observe(element); - } - } - - onResize: ResizeObserverCallback = (entries: ResizeObserverEntry[]) => { - const { onResize } = this.props; - - const target = entries[0].target as HTMLElement; - - const { width, height } = target.getBoundingClientRect(); - const { offsetWidth, offsetHeight } = target; - - /** - * Resize observer trigger when content size changed. - * In most case we just care about element size, - * let's use `boundary` instead of `contentRect` here to avoid shaking. - */ - const fixedWidth = Math.floor(width); - const fixedHeight = Math.floor(height); - - if ( - this.state.width !== fixedWidth || - this.state.height !== fixedHeight || - this.state.offsetWidth !== offsetWidth || - this.state.offsetHeight !== offsetHeight - ) { - const size = { width: fixedWidth, height: fixedHeight, offsetWidth, offsetHeight }; - - this.setState(size); - - if (onResize) { - const mergedOffsetWidth = offsetWidth === Math.round(width) ? width : offsetWidth; - const mergedOffsetHeight = offsetHeight === Math.round(height) ? height : offsetHeight; - - // defer the callback but not defer to next frame - Promise.resolve().then(() => { - onResize( - { - ...size, - offsetWidth: mergedOffsetWidth, - offsetHeight: mergedOffsetHeight, - }, - target, - ); - }); - } - } - }; - - setChildNode = (node: RefNode) => { - this.childNode = node; - }; - - destroyObserver() { - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = null; - } - } +export interface ResizeObserverProps { + /** Pass to ResizeObserver.Collection with additional data */ + data?: any; + children: React.ReactNode; + disabled?: boolean; + /** Trigger if element resized. Will always trigger when first time render. */ + onResize?: OnResize; +} - render() { - const { children } = this.props; - const childNodes = toArray(children); +function ResizeObserver(props: ResizeObserverProps) { + const { children } = props; + const childNodes = toArray(children); + if (process.env.NODE_ENV !== 'production') { if (childNodes.length > 1) { warning( false, - 'Find more than one child node with `children` in ResizeObserver. Will only observe first one.', + 'Find more than one child node with `children` in ResizeObserver. Please use ResizeObserver.Collection instead.', ); } else if (childNodes.length === 0) { warning(false, '`children` of ResizeObserver is empty. Nothing is in observe.'); - - return null; } - - const childNode = childNodes[0]; - - if (React.isValidElement(childNode) && supportRef(childNode)) { - const { ref } = childNode as any; - - childNodes[0] = React.cloneElement(childNode as any, { - ref: composeRef(ref, this.setChildNode), - }); - } - - return childNodes.length === 1 - ? childNodes[0] - : childNodes.map((node, index) => { - if (!React.isValidElement(node) || ('key' in node && node.key !== null)) { - return node; - } - - return React.cloneElement(node, { - key: `${INTERNAL_PREFIX_KEY}-${index}`, - }); - }); } + + return childNodes.map((child, index) => { + const key = child?.key || `${INTERNAL_PREFIX_KEY}-${index}`; + return ( + + {child} + + ); + }) as any as React.ReactElement; } -export default ReactResizeObserver; +ResizeObserver.Collection = Collection; + +export default ResizeObserver; diff --git a/src/utils/observerUtil.ts b/src/utils/observerUtil.ts new file mode 100644 index 0000000..72da913 --- /dev/null +++ b/src/utils/observerUtil.ts @@ -0,0 +1,40 @@ +import ResizeObserver from 'resize-observer-polyfill'; + +export type ResizeListener = (element: Element) => void; + +// =============================== Const =============================== +const elementListeners = new Map>(); + +function onResize(entities: ResizeObserverEntry[]) { + entities.forEach(entity => { + const { target } = entity; + elementListeners.get(target)?.forEach(listener => listener(target)); + }); +} + +// Note: ResizeObserver polyfill not support option to measure border-box resize +const resizeObserver = new ResizeObserver(onResize); + +// Dev env only +export const _el = process.env.NODE_ENV !== 'production' ? elementListeners : null; // eslint-disable-line +export const _rs = process.env.NODE_ENV !== 'production' ? onResize : null; // eslint-disable-line + +// ============================== Observe ============================== +export function observe(element: Element, callback: ResizeListener) { + if (!elementListeners.has(element)) { + elementListeners.set(element, new Set()); + resizeObserver.observe(element); + } + + elementListeners.get(element).add(callback); +} + +export function unobserve(element: Element, callback: ResizeListener) { + if (elementListeners.has(element)) { + elementListeners.get(element).delete(callback); + if (!elementListeners.get(element).size) { + resizeObserver.unobserve(element); + elementListeners.delete(element); + } + } +} diff --git a/tests/Collection.spec.js b/tests/Collection.spec.js new file mode 100644 index 0000000..3c60776 --- /dev/null +++ b/tests/Collection.spec.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import 'regenerator-runtime'; +import ResizeObserver from '../src'; +import { spyElementPrototypes } from './utils/domHook'; + +describe('ResizeObserver.Collection', () => { + let domSpy; + let mockWidth; + let mockHeight; + let mockOffsetWidth; + let mockOffsetHeight; + + beforeAll(() => { + domSpy = spyElementPrototypes(HTMLElement, { + getBoundingClientRect: () => ({ + width: mockWidth, + height: mockHeight, + }), + offsetWidth: { + get: () => mockOffsetWidth, + }, + offsetHeight: { + get: () => mockOffsetHeight, + }, + }); + }); + + afterAll(() => { + domSpy.mockRestore(); + }); + + it('batch collection', async () => { + const onBatchResize = jest.fn(); + + const wrapper = mount( + + +
+ + +
+ + , + ); + + // Resize div1 + wrapper.triggerResize(0); + await Promise.resolve(); + expect(onBatchResize).toHaveBeenCalledWith([ + expect.objectContaining({ element: wrapper.find('#div1').getDOMNode() }), + ]); + + // Resize both + onBatchResize.mockReset(); + wrapper.triggerResize(0); + wrapper.triggerResize(1); + await Promise.resolve(); + expect(onBatchResize).toHaveBeenCalledWith([ + expect.objectContaining({ element: wrapper.find('#div1').getDOMNode() }), + expect.objectContaining({ element: wrapper.find('#div2').getDOMNode() }), + ]); + + // Resize div2 + onBatchResize.mockReset(); + wrapper.triggerResize(1); + await Promise.resolve(); + expect(onBatchResize).toHaveBeenCalledWith([ + expect.objectContaining({ element: wrapper.find('#div2').getDOMNode() }), + ]); + }); +}); diff --git a/tests/index.spec.js b/tests/index.spec.js index 677ed63..9726ad3 100644 --- a/tests/index.spec.js +++ b/tests/index.spec.js @@ -3,6 +3,7 @@ import { mount } from 'enzyme'; import 'regenerator-runtime'; import ResizeObserver from '../src'; import { spyElementPrototypes } from './utils/domHook'; +import { _el as elementListeners } from '../src/utils/observerUtil'; describe('ResizeObserver', () => { let errorSpy; @@ -55,7 +56,7 @@ describe('ResizeObserver', () => { ); expect(errorSpy).toHaveBeenCalledWith( - 'Warning: Find more than one child node with `children` in ResizeObserver. Will only observe first one.', + 'Warning: Find more than one child node with `children` in ResizeObserver. Please use ResizeObserver.Collection instead.', ); expect(wrapper.find('div').first().key()).toEqual('exist-key'); @@ -97,7 +98,7 @@ describe('ResizeObserver', () => { wrapper.triggerResize(); await Promise.resolve(); - expect(wrapper.instance().currentElement).toBeTruthy(); + expect(wrapper.exists('DomWrapper')).toBeTruthy(); // Dom exist expect(onResize).toHaveBeenCalled(); }); @@ -164,7 +165,7 @@ describe('ResizeObserver', () => { ); wrapper.setProps({ disabled: true }); - expect(wrapper.findObserver().instance().resizeObserver).toBeFalsy(); + expect(elementListeners.get(wrapper.getDOMNode())).toBeFalsy(); }); it('unmount to clear', () => { @@ -173,10 +174,12 @@ describe('ResizeObserver', () => {
, ); + const dom = wrapper.getDOMNode(); + expect(elementListeners.get(dom)).toBeTruthy(); - const instance = wrapper.findObserver().instance(); + // Unmount wrapper.unmount(); - expect(instance.resizeObserver).toBeFalsy(); + expect(elementListeners.get(dom)).toBeFalsy(); }); describe('work with child type', () => { diff --git a/tests/setup.js b/tests/setup.js index 26c6ea0..03435d2 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,14 +1,15 @@ const Enzyme = require('enzyme'); const Adapter = require('enzyme-adapter-react-16'); +const { _rs: onResize } = require('../src/utils/observerUtil'); Enzyme.configure({ adapter: new Adapter() }); Object.assign(Enzyme.ReactWrapper.prototype, { - findObserver() { - return this.find('ResizeObserver'); + findObserver(index = 0) { + return this.find('ResizeObserver').at(index); }, - triggerResize() { - const ob = this.findObserver(); - ob.instance().onResize([{ target: ob.getDOMNode() }]); + triggerResize(index = 0) { + const target = this.findObserver(index).getDOMNode(); + onResize([{ target }]); }, });