├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmignore ├── .size-limit.json ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── browserslist ├── karma.conf.js ├── media └── Logo.png ├── package.json ├── rollup.config.js ├── src ├── ResizeObserver.d.ts └── index.ts ├── tests ├── .gitignore ├── basic.tsx ├── ssr.test.tsx ├── ssr │ ├── Test.js │ ├── create-ssr-test.js │ └── ssr.template.tsx ├── testing-lib.tsx ├── tsconfig.json └── utils │ └── index.tsx └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/typescript", ["@babel/preset-env", { "loose": true }]] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | tab_width = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | node_modules 3 | .idea 4 | .vscode 5 | yarn.lock 6 | yarn-error.log* 7 | dist 8 | /polyfilled.js 9 | /polyfilled.d.ts 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .idea 3 | yarn.lock 4 | yarn-error.log 5 | .DS_Store 6 | .babelrc 7 | rollup.config.js 8 | karma.conf.js 9 | .editorconfig 10 | browserslist 11 | .cache 12 | .travis.yml 13 | tests 14 | tsconfig.json 15 | media 16 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "dist/bundle.esm.js", 4 | "limit": "357 B", 5 | "gzip": true 6 | }, 7 | { 8 | "path": "dist/bundle.esm.js", 9 | "limit": "281 B", 10 | "brotli": true 11 | }, 12 | { 13 | "path": "dist/bundle.cjs.js", 14 | "limit": "346 B", 15 | "gzip": true 16 | }, 17 | { 18 | "path": "dist/bundle.cjs.js", 19 | "limit": "261 B", 20 | "brotli": true 21 | }, 22 | { 23 | "path": "polyfilled.js", 24 | "limit": "2678 B", 25 | "gzip": true 26 | }, 27 | { 28 | "path": "polyfilled.js", 29 | "limit": "2381 B", 30 | "brotli": true 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 11.7.0 4 | env: 5 | global: 6 | - MOZ_HEADLESS=1 7 | addons: 8 | chrome: stable 9 | firefox: latest 10 | script: 11 | - yarn test 12 | deploy: 13 | provider: npm 14 | email: steveruizok@gmail.com 15 | api_key: 16 | secure: not-secure 17 | on: 18 | tags: false 19 | repo: steveruizok/use-motion-resize-observer 20 | branch: master 21 | skip_cleanup: true 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.0.0 4 | 5 | Upgraded to Framer Motion 3. 6 | 7 | ## 1.0.2 8 | 9 | - One more test. 10 | 11 | ## 1.0.1 12 | 13 | - Fixes bug with onResize callback. 14 | 15 | ## 1.0.0 16 | 17 | - Adds Framer Motion, adapts docs. I'm new to forking, so in case I've forgot to say elsewhere: thanks so much to ZeeCoder for this amazing library and extremely instructive docs/repo. 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2020 Steve Ruiz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # use-motion-resize-observer 2 | 3 | A fork of [use-resize-observer](https://github.com/ZeeCoder/use-resize-observer) that uses Motion Values from Framer Motion. 4 | 5 | ## What's different? 6 | 7 | Unlike `useMotionResizeObserver`, the hook will **not** trigger a re-render on all changes to the target element's width and / or height. Instead, it will update the `height` and `width` motion values. You can use these values to drive other changes to `motion` components. 8 | 9 | ## In Action 10 | 11 | [CodeSandbox Demo](https://codesandbox.io/s/use-motion-resize-observer-basic-usage-cmfdi) 12 | 13 | ## Install 14 | 15 | ```sh 16 | yarn add use-motion-resize-observer --dev 17 | # or 18 | npm install use-motion-resize-observer --save-dev 19 | ``` 20 | 21 | ## Basic Usage 22 | 23 | Note that the default builds are not polyfilled! For instructions and alternatives, see the [Transpilation / Polyfilling](#transpilation--polyfilling) section. 24 | 25 | ```js 26 | import React from "react"; 27 | import useMotionResizeObserver from "use-motion-resize-observer"; 28 | import { motion, useTransform } from "framer-motion"; 29 | 30 | const App = () => { 31 | const { ref, width, height } = useMotionResizeObserver({ 32 | initial: { width: 100, height: 100 }, 33 | }); 34 | 35 | const background = useTransform(width, [100, 300], ["#52cb9a", "#2d8a9a"]); 36 | 37 | return ; 38 | }; 39 | ``` 40 | 41 | ## Passing in Your Own `ref` 42 | 43 | You can pass in your own ref instead of using the one provided. 44 | This can be useful if you already have a ref you want to measure. 45 | 46 | ```js 47 | const ref = useRef(null); 48 | const { width, height } = useMotionResizeObserver({ ref }); 49 | ``` 50 | 51 | You can even reuse the same hook instance to measure different elements: 52 | 53 | [CodeSandbox Demo](https://codesandbox.io/s/use-resize-observer-changing-measured-ref-or4uj) 54 | 55 | ## The "onResize" callback 56 | 57 | You can provide an `onResize` callback function, which will receive the width and height of the element as numbers (not motion values) when it changes, so 58 | that you can decide what to do with it: 59 | 60 | ```js 61 | import React from "react"; 62 | import useMotionResizeObserver from "use-motion-resize-observer"; 63 | 64 | const App = () => { 65 | const { ref } = useMotionResizeObserver({ 66 | onResize: ({ width, height }) => { 67 | // do something here. 68 | }, 69 | }); 70 | 71 | return
; 72 | }; 73 | ``` 74 | 75 | ## Defaults (SSR) 76 | 77 | On initial mount the ResizeObserver will take a little time to report on the 78 | actual size. 79 | 80 | Until the hook receives the first measurement, it returns `0` for width 81 | and height by default. 82 | 83 | You can override this behaviour, which could be useful for SSR as well. 84 | 85 | ```js 86 | const { ref, width, height } = useMotionResizeObserver({ 87 | initial: { width: 100, height: 50 }, 88 | }); 89 | ``` 90 | 91 | Here "width" and "height" motion values will be 100 and 50 respectively, until the ResizeObserver kicks in and reports the actual size. 92 | 93 | ## Without Defaults 94 | 95 | If you only want real measurements (only values from the ResizeObserver without 96 | any default values), then you can just leave defaults off: 97 | 98 | ```js 99 | const { ref, width, height } = useMotionResizeObserver(); 100 | ``` 101 | 102 | Here the "width" and "height" motion values will have `0` values until the ResizeObserver takes its first measurement. 103 | 104 | ```js 105 | const { ref, width, height } = useMotionResizeObserver(); 106 | ``` 107 | 108 | ## Container/Element Query with CSS-in-JS 109 | 110 | It's possible to apply styles conditionally based on the width / height of an 111 | element using a CSS-in-JS solution, which is the basic idea behind 112 | container/element queries: 113 | 114 | [CodeSandbox Demo](https://codesandbox.io/s/use-resize-observer-container-query-with-css-in-js-b8slq) 115 | 116 | ...but this is much easier with `motion` elements from Framer Motion. 117 | 118 | ```jsx 119 | const { ref, width, height } = useMotionResizeObserver(); 120 | const background = useTransform(width, [100, 300], ["#52cb9a", "#2d8a9a"]); 121 | 122 | return ; 123 | ``` 124 | 125 | ## Changing State 126 | 127 | The goal of Framer Motion is to allow for visual data to flow without triggering component re-renders by avoiding React's state/props. That said, if you _do_ want to change state, you can use the `onResize` callback. 128 | 129 | ```js 130 | const { ref, width, height } = useMotionResizeObserver({ 131 | onResize: (size) => setSize(size), 132 | }); 133 | ``` 134 | 135 | [CodeSandbox Demo](https://codesandbox.io/s/use-motion-resize-observer-changing-state-sg8qb) 136 | 137 | ## Transpilation / Polyfilling 138 | 139 | By default the library provides transpiled ES5 modules in CJS / ESM module formats. 140 | 141 | Polyfilling is recommended to be done in the host app, and not within imported 142 | libraries, as that way consumers have control over the exact polyfills being used. 143 | 144 | That said, there's a [polyfilled](https://github.com/que-etc/resize-observer-polyfill) 145 | CJS module that can be used for convenience (Not affecting globals): 146 | 147 | ```js 148 | import useMotionResizeObserver from "use-motion-resize-observer/polyfilled"; 149 | ``` 150 | 151 | ## Related 152 | 153 | - [@zeecoder/use-resize-observer](https://github.com/ZeeCoder/container-query) 154 | - [@zeecoder/container-query](https://github.com/ZeeCoder/container-query) 155 | - [@zeecoder/react-resize-observer](https://github.com/ZeeCoder/react-resize-observer) 156 | 157 | ## License 158 | 159 | MIT 160 | -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | # Using IE to declare a fixed point. 2 | # "Last n versions" and such could potentially change the build as time goes on, 3 | # while this will transpile down to ES5 that'll run in most browsers you probably 4 | # care about. 5 | IE >= 11 6 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | const browsers = (process.env.KARMA_BROWSERS || "ChromeHeadless").split(","); 3 | 4 | const testFilePattern = "tests/*.tsx"; 5 | 6 | config.set({ 7 | basePath: ".", 8 | frameworks: ["jasmine"], 9 | files: [ 10 | { 11 | pattern: testFilePattern, 12 | watched: true, 13 | }, 14 | ], 15 | autoWatch: true, 16 | 17 | browsers, 18 | reporters: ["spec"], 19 | preprocessors: { 20 | [testFilePattern]: ["webpack", "sourcemap"], 21 | }, 22 | 23 | // Max concurrency for SauceLabs OS plan 24 | concurrency: 5, 25 | 26 | client: { 27 | jasmine: { 28 | // Order of the tests matter, so don't randomise it 29 | random: false, 30 | }, 31 | }, 32 | 33 | webpack: { 34 | mode: "development", 35 | devtool: "inline-source-map", 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.(ts|tsx|js|jsx)$/, 40 | exclude: /node_modules/, 41 | use: { 42 | loader: "babel-loader", 43 | options: { 44 | presets: [ 45 | ["@babel/preset-env", { loose: true, modules: false }], 46 | "@babel/preset-react", 47 | "@babel/preset-typescript", 48 | ], 49 | plugins: [["@babel/transform-runtime", { useESModules: true }]], 50 | }, 51 | }, 52 | }, 53 | ], 54 | }, 55 | resolve: { 56 | extensions: [".ts", ".tsx", ".js", ".jsx"], 57 | }, 58 | }, 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /media/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/use-motion-resize-observer/fcedfd31d0930e63442af340bec3400038db0e02/media/Logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-motion-resize-observer", 3 | "version": "2.0.0", 4 | "main": "dist/bundle.cjs.js", 5 | "module": "dist/bundle.esm.js", 6 | "types": "dist/index.d.ts", 7 | "sideEffects": false, 8 | "repository": "git@github.com:steveruizok/use-motion-resize-observer.git", 9 | "description": "A React hook that allows you to use a ResizeObserver to measure an element's size (using Motion Values).", 10 | "author": "Steve Ruiz ", 11 | "license": "MIT", 12 | "scripts": { 13 | "build": "rollup -c && tsc && cp dist/index.d.ts polyfilled.d.ts", 14 | "watch": "KARMA_BROWSERS=Chrome run-p 'src:watch' 'karma:watch'", 15 | "src:watch": "rollup -c -w", 16 | "check:size": "size-limit", 17 | "check:types": "tsc -p tests", 18 | "test": "run-s 'build' 'check:size' 'check:types' 'test:create:ssr' 'test:headless:*'", 19 | "test:create:ssr": "node ./tests/ssr/create-ssr-test.js", 20 | "test:chrome": "KARMA_BROWSERS=Chrome yarn karma:run", 21 | "test:headless:chrome": "KARMA_BROWSERS=ChromeHeadless yarn karma:run", 22 | "test:firefox": "KARMA_BROWSERS=Firefox yarn karma:run", 23 | "test:headless:firefox": "KARMA_BROWSERS=FirefoxHeadless yarn karma:run", 24 | "karma:run": "karma start --singleRun", 25 | "karma:watch": "karma start", 26 | "prepublish": "yarn build" 27 | }, 28 | "husky": { 29 | "hooks": { 30 | "pre-commit": "lint-staged" 31 | } 32 | }, 33 | "lint-staged": { 34 | "*.{js,ts,md}": [ 35 | "prettier --write" 36 | ] 37 | }, 38 | "peerDependencies": { 39 | "react": ">=16.8.0", 40 | "react-dom": ">=16.8.0" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.7.7", 44 | "@babel/plugin-transform-runtime": "^7.9.0", 45 | "@babel/preset-env": "^7.7.7", 46 | "@babel/preset-react": "^7.9.4", 47 | "@babel/preset-typescript": "^7.9.0", 48 | "@rollup/plugin-inject": "^4.0.1", 49 | "@size-limit/preset-small-lib": "^4.4.5", 50 | "@testing-library/react": "^10.0.2", 51 | "@types/karma": "^5.0.0", 52 | "@types/karma-jasmine": "^3.1.0", 53 | "@types/react": "^16.9.34", 54 | "@types/react-dom": "^16.9.6", 55 | "babel-loader": "^8.1.0", 56 | "delay": "^4.1.0", 57 | "husky": "^4.2.5", 58 | "karma": "^5.0.1", 59 | "karma-chrome-launcher": "^3.0.0", 60 | "karma-firefox-launcher": "^1.3.0", 61 | "karma-jasmine": "^3.1.1", 62 | "karma-sourcemap-loader": "^0.3.7", 63 | "karma-spec-reporter": "^0.0.32", 64 | "karma-webpack": "^4.0.2", 65 | "lint-staged": "^10.1.3", 66 | "npm-run-all": "^4.1.5", 67 | "prettier": "^2.0.4", 68 | "react": "^16.9.0", 69 | "react-dom": "^16.9.0", 70 | "rollup": "^2.6.1", 71 | "rollup-plugin-babel": "^4.4.0", 72 | "size-limit": "^4.4.5", 73 | "typescript": "^3.8.3" 74 | }, 75 | "dependencies": { 76 | "framer-motion": "^3.1.1", 77 | "resize-observer-polyfill": "^1.5.1" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import inject from "@rollup/plugin-inject"; 3 | 4 | const getConfig = ({ polyfill = false } = {}) => { 5 | const config = { 6 | input: "src/index.ts", 7 | output: [], 8 | plugins: [babel({ extensions: ["ts"] })], 9 | external: ["react"], 10 | }; 11 | 12 | if (polyfill) { 13 | config.output = [ 14 | { 15 | file: "polyfilled.js", 16 | format: "cjs", 17 | }, 18 | ]; 19 | config.external.push("resize-observer-polyfill"); 20 | config.plugins.push( 21 | inject({ 22 | ResizeObserver: "resize-observer-polyfill", 23 | }) 24 | ); 25 | } else { 26 | config.output = [ 27 | { 28 | file: "dist/bundle.cjs.js", 29 | format: "cjs", 30 | }, 31 | { 32 | file: "dist/bundle.esm.js", 33 | format: "esm", 34 | }, 35 | ]; 36 | } 37 | 38 | return config; 39 | }; 40 | 41 | export default [getConfig(), getConfig({ polyfill: true })]; 42 | -------------------------------------------------------------------------------- /src/ResizeObserver.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The **ResizeObserver** interface reports changes to the dimensions of an 3 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element)'s content 4 | * or border box, or the bounding box of an 5 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). 6 | * 7 | * > **Note**: The content box is the box in which content can be placed, 8 | * > meaning the border box minus the padding and border width. The border box 9 | * > encompasses the content, padding, and border. See 10 | * > [The box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model) 11 | * > for further explanation. 12 | * 13 | * `ResizeObserver` avoids infinite callback loops and cyclic dependencies that 14 | * are often created when resizing via a callback function. It does this by only 15 | * processing elements deeper in the DOM in subsequent frames. Implementations 16 | * should, if they follow the specification, invoke resize events before paint 17 | * and after layout. 18 | * 19 | * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver 20 | * 21 | * Big thanks for Jason Strothmann for sharing this under the MIT licence: 22 | * @see https://gist.github.com/strothj/708afcf4f01dd04de8f49c92e88093c3 23 | */ 24 | declare class ResizeObserver { 25 | /** 26 | * The **ResizeObserver** constructor creates a new `ResizeObserver` object, 27 | * which can be used to report changes to the content or border box of an 28 | * `Element` or the bounding box of an `SVGElement`. 29 | * 30 | * @example 31 | * var ResizeObserver = new ResizeObserver(callback) 32 | * 33 | * @param callback 34 | * The function called whenever an observed resize occurs. The function is 35 | * called with two parameters: 36 | * * **entries** 37 | * An array of 38 | * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) 39 | * objects that can be used to access the new dimensions of the element 40 | * after each change. 41 | * * **observer** 42 | * A reference to the `ResizeObserver` itself, so it will definitely be 43 | * accessible from inside the callback, should you need it. This could be 44 | * used for example to automatically unobserve the observer when a certain 45 | * condition is reached, but you can omit it if you don't need it. 46 | * 47 | * The callback will generally follow a pattern along the lines of: 48 | * ```js 49 | * function(entries, observer) { 50 | * for (let entry of entries) { 51 | * // Do something to each entry 52 | * // and possibly something to the observer itself 53 | * } 54 | * } 55 | * ``` 56 | * 57 | * The following snippet is taken from the 58 | * [resize-observer-text.html](https://mdn.github.io/dom-examples/resize-observer/resize-observer-text.html) 59 | * ([see source](https://github.com/mdn/dom-examples/blob/master/resize-observer/resize-observer-text.html)) 60 | * example: 61 | * @example 62 | * const resizeObserver = new ResizeObserver(entries => { 63 | * for (let entry of entries) { 64 | * if(entry.contentBoxSize) { 65 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; 66 | * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; 67 | * } else { 68 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; 69 | * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; 70 | * } 71 | * } 72 | * }); 73 | * 74 | * resizeObserver.observe(divElem); 75 | */ 76 | constructor(callback: ResizeObserverCallback); 77 | 78 | /** 79 | * The **disconnect()** method of the 80 | * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 81 | * interface unobserves all observed 82 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 83 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 84 | * targets. 85 | */ 86 | disconnect: () => void; 87 | 88 | /** 89 | * The `observe()` method of the 90 | * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 91 | * interface starts observing the specified 92 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 93 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). 94 | * 95 | * @example 96 | * resizeObserver.observe(target, options); 97 | * 98 | * @param target 99 | * A reference to an 100 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 101 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 102 | * to be observed. 103 | * 104 | * @param options 105 | * An options object allowing you to set options for the observation. 106 | * Currently this only has one possible option that can be set. 107 | */ 108 | observe: (target: Element, options?: ResizeObserverObserveOptions) => void; 109 | 110 | /** 111 | * The **unobserve()** method of the 112 | * [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) 113 | * interface ends the observing of a specified 114 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 115 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement). 116 | */ 117 | unobserve: (target: Element) => void; 118 | } 119 | 120 | interface ResizeObserverObserveOptions { 121 | /** 122 | * Sets which box model the observer will observe changes to. Possible values 123 | * are `content-box` (the default), and `border-box`. 124 | * 125 | * @default "content-box" 126 | */ 127 | box?: "content-box" | "border-box"; 128 | } 129 | 130 | /** 131 | * The function called whenever an observed resize occurs. The function is 132 | * called with two parameters: 133 | * 134 | * @param entries 135 | * An array of 136 | * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) 137 | * objects that can be used to access the new dimensions of the element after 138 | * each change. 139 | * 140 | * @param observer 141 | * A reference to the `ResizeObserver` itself, so it will definitely be 142 | * accessible from inside the callback, should you need it. This could be used 143 | * for example to automatically unobserve the observer when a certain condition 144 | * is reached, but you can omit it if you don't need it. 145 | * 146 | * The callback will generally follow a pattern along the lines of: 147 | * @example 148 | * function(entries, observer) { 149 | * for (let entry of entries) { 150 | * // Do something to each entry 151 | * // and possibly something to the observer itself 152 | * } 153 | * } 154 | * 155 | * @example 156 | * const resizeObserver = new ResizeObserver(entries => { 157 | * for (let entry of entries) { 158 | * if(entry.contentBoxSize) { 159 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentBoxSize.inlineSize/200) + 'rem'; 160 | * pElem.style.fontSize = Math.max(1, entry.contentBoxSize.inlineSize/600) + 'rem'; 161 | * } else { 162 | * h1Elem.style.fontSize = Math.max(1.5, entry.contentRect.width/200) + 'rem'; 163 | * pElem.style.fontSize = Math.max(1, entry.contentRect.width/600) + 'rem'; 164 | * } 165 | * } 166 | * }); 167 | * 168 | * resizeObserver.observe(divElem); 169 | */ 170 | type ResizeObserverCallback = ( 171 | entries: ResizeObserverEntry[], 172 | observer: ResizeObserver 173 | ) => void; 174 | 175 | /** 176 | * The **ResizeObserverEntry** interface represents the object passed to the 177 | * [ResizeObserver()](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver) 178 | * constructor's callback function, which allows you to access the new 179 | * dimensions of the 180 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 181 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 182 | * being observed. 183 | */ 184 | interface ResizeObserverEntry { 185 | /** 186 | * An object containing the new border box size of the observed element when 187 | * the callback is run. 188 | */ 189 | readonly borderBoxSize: ResizeObserverEntryBoxSize; 190 | 191 | /** 192 | * An object containing the new content box size of the observed element when 193 | * the callback is run. 194 | */ 195 | readonly contentBoxSize: ResizeObserverEntryBoxSize; 196 | 197 | /** 198 | * A [DOMRectReadOnly](https://developer.mozilla.org/en-US/docs/Web/API/DOMRectReadOnly) 199 | * object containing the new size of the observed element when the callback is 200 | * run. Note that this is better supported than the above two properties, but 201 | * it is left over from an earlier implementation of the Resize Observer API, 202 | * is still included in the spec for web compat reasons, and may be deprecated 203 | * in future versions. 204 | */ 205 | // node_modules/typescript/lib/lib.dom.d.ts 206 | readonly contentRect: DOMRectReadOnly; 207 | 208 | /** 209 | * A reference to the 210 | * [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element) or 211 | * [SVGElement](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) 212 | * being observed. 213 | */ 214 | readonly target: Element; 215 | } 216 | 217 | /** 218 | * The **borderBoxSize** read-only property of the 219 | * [ResizeObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry) 220 | * interface returns an object containing the new border box size of the 221 | * observed element when the callback is run. 222 | */ 223 | interface ResizeObserverEntryBoxSize { 224 | /** 225 | * The length of the observed element's border box in the block dimension. For 226 | * boxes with a horizontal 227 | * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), 228 | * this is the vertical dimension, or height; if the writing-mode is vertical, 229 | * this is the horizontal dimension, or width. 230 | */ 231 | blockSize: number; 232 | 233 | /** 234 | * The length of the observed element's border box in the inline dimension. 235 | * For boxes with a horizontal 236 | * [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), 237 | * this is the horizontal dimension, or width; if the writing-mode is 238 | * vertical, this is the vertical dimension, or height. 239 | */ 240 | inlineSize: number; 241 | } 242 | 243 | interface Window { 244 | ResizeObserver: ResizeObserver; 245 | } 246 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useLayoutEffect, 3 | useRef, 4 | useMemo, 5 | RefObject, 6 | MutableRefObject, 7 | } from "react"; 8 | 9 | import { MotionValue, useMotionValue } from "framer-motion"; 10 | 11 | type ObservedSize = { 12 | width: number | undefined; 13 | height: number | undefined; 14 | }; 15 | 16 | type MotionSize = { 17 | width: MotionValue; 18 | height: MotionValue; 19 | }; 20 | 21 | type ResizeHandler = (size: ObservedSize) => void; 22 | 23 | // Type definition when the user wants the hook to provide the ref with the given type. 24 | function useMotionResizeObserver(opts?: { 25 | onResize?: ResizeHandler; 26 | initial?: { 27 | width: number; 28 | height: number; 29 | }; 30 | }): { ref: RefObject } & MotionSize; 31 | 32 | // Type definition when the hook just passes through the user provided ref. 33 | function useMotionResizeObserver(opts?: { 34 | ref: RefObject; 35 | onResize?: ResizeHandler; 36 | initial?: { 37 | width: number; 38 | height: number; 39 | }; 40 | }): { ref: RefObject } & MotionSize; 41 | 42 | function useMotionResizeObserver( 43 | opts: { 44 | ref?: RefObject; 45 | onResize?: ResizeHandler; 46 | initial?: { 47 | width: number; 48 | height: number; 49 | }; 50 | } = {} 51 | ): { ref: RefObject } & MotionSize { 52 | // `defaultRef` Has to be non-conditionally declared here whether or not it'll 53 | // be used as that's how hooks work. 54 | // @see https://reactjs.org/docs/hooks-rules.html#explanation 55 | const defaultRef = useRef(null); 56 | 57 | // Saving the callback as a ref. With this, I don't need to put onResize in the 58 | // effect dep array, and just passing in an anonymous function without memoising 59 | // will not reinstantiate the hook's ResizeObserver 60 | const onResize = opts.onResize; 61 | const onResizeRef = useRef(undefined); 62 | onResizeRef.current = onResize; 63 | 64 | // Using a single instance throughought the hook's lifetime 65 | const resizeObserverRef = useRef() as MutableRefObject< 66 | ResizeObserver 67 | >; 68 | 69 | const ref = opts.ref || defaultRef; 70 | 71 | const width = useMotionValue(opts.initial ? opts.initial.width : 0); 72 | const height = useMotionValue(opts.initial ? opts.initial.height : 0); 73 | 74 | // Using a ref to track the previous width / height to avoid unnecessary renders 75 | const previous: { 76 | current: { 77 | width?: number; 78 | height?: number; 79 | }; 80 | } = useRef({ 81 | width: undefined, 82 | height: undefined, 83 | }); 84 | 85 | useLayoutEffect(() => { 86 | if (resizeObserverRef.current) { 87 | return; 88 | } 89 | 90 | resizeObserverRef.current = new ResizeObserver((entries) => { 91 | if (!Array.isArray(entries)) { 92 | return; 93 | } 94 | 95 | // Since we only observe the one element, we don't need to loop 96 | if (!entries.length) { 97 | return; 98 | } 99 | 100 | const entry = entries[0]; 101 | 102 | // `Math.round` is in line with how CSS resolves sub-pixel values 103 | const newWidth = Math.round(entry.contentRect.width); 104 | const newHeight = Math.round(entry.contentRect.height); 105 | if ( 106 | previous.current.width !== newWidth || 107 | previous.current.height !== newHeight 108 | ) { 109 | const newSize = { width: newWidth, height: newHeight }; 110 | if (onResizeRef.current) { 111 | onResizeRef.current(newSize); 112 | } 113 | previous.current.width = newWidth; 114 | previous.current.height = newHeight; 115 | width.set(newWidth); 116 | height.set(newHeight); 117 | } 118 | }); 119 | }, [width, height, resizeObserverRef]); 120 | 121 | useLayoutEffect(() => { 122 | if ( 123 | typeof ref !== "object" || 124 | ref === null || 125 | !(ref.current instanceof Element) 126 | ) { 127 | return; 128 | } 129 | 130 | const element = ref.current; 131 | 132 | resizeObserverRef.current.observe(element); 133 | 134 | return () => resizeObserverRef.current.unobserve(element); 135 | }, [ref]); 136 | 137 | return useMemo(() => ({ ref, width, height }), [ref, width, height]); 138 | } 139 | 140 | export default useMotionResizeObserver; 141 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | ssr.test.js 2 | -------------------------------------------------------------------------------- /tests/basic.tsx: -------------------------------------------------------------------------------- 1 | // todo test for SSR 2 | import React, { 3 | useEffect, 4 | useState, 5 | useRef, 6 | RefObject, 7 | FunctionComponent, 8 | } from "react"; 9 | import useResizeObserver from "../"; 10 | import useResizeObserverPolyfilled from "../polyfilled"; 11 | import delay from "delay"; 12 | import { 13 | createComponentHandler, 14 | Observed, 15 | render, 16 | HandlerReceiver, 17 | MotionSize, 18 | ObservedSize, 19 | MultiHandlerResolverComponentProps, 20 | ComponentHandler, 21 | HandlerResolverComponentProps, 22 | } from "./utils"; 23 | 24 | describe("Vanilla tests", () => { 25 | it("should render with undefined sizes at first", async () => { 26 | const handler = await render(Observed); 27 | handler.assertDefaultSize(); 28 | }); 29 | 30 | it("should render with custom defaults", async () => { 31 | const { assertSize } = await render( 32 | Observed, 33 | {}, 34 | { 35 | defaultWidth: 24, 36 | defaultHeight: 42, 37 | } 38 | ); 39 | 40 | // By running this assertion immediately, it should check the default values 41 | // instead ot the first on-mount measurement. 42 | assertSize({ width: 24, height: 42 }); 43 | }); 44 | 45 | it("should follow size changes correctly with appropriate render count and without sub-pixels as they're used in CSS", async () => { 46 | const { 47 | setAndAssertSize, 48 | setSize, 49 | assertSize, 50 | assertRenderCount, 51 | } = await render(Observed, { waitForFirstMeasurement: true }); 52 | 53 | // Default render + first measurement 54 | assertRenderCount(1); 55 | 56 | await setAndAssertSize({ width: 100, height: 200 }); 57 | assertRenderCount(1); 58 | 59 | setSize({ width: 321, height: 456 }); 60 | await delay(50); 61 | assertSize({ width: 321, height: 456 }); 62 | assertRenderCount(1); 63 | }); 64 | 65 | it("should handle multiple instances", async () => { 66 | const Test: FunctionComponent = ({ 67 | resolveHandler, 68 | }) => { 69 | let resolveHandler1: HandlerReceiver = () => {}; 70 | let resolveHandler2: HandlerReceiver = () => {}; 71 | 72 | const handlersPromise = Promise.all([ 73 | new Promise( 74 | (resolve) => (resolveHandler1 = resolve as HandlerReceiver) 75 | ), 76 | new Promise( 77 | (resolve) => (resolveHandler2 = resolve as HandlerReceiver) 78 | ), 79 | ]); 80 | 81 | useEffect(() => { 82 | handlersPromise.then( 83 | ([handler1, handler2]: [ComponentHandler, ComponentHandler]) => { 84 | resolveHandler([handler1, handler2]); 85 | } 86 | ); 87 | }, []); 88 | 89 | return ( 90 | <> 91 | 92 | 93 | 94 | ); 95 | }; 96 | const [handler1, handler2] = await render(Test); 97 | 98 | await Promise.all([ 99 | handler1.setAndAssertSize({ width: 100, height: 200 }), 100 | handler2.setAndAssertSize({ width: 300, height: 400 }), 101 | ]); 102 | 103 | handler1.assertRenderCount(1); 104 | handler2.assertRenderCount(1); 105 | 106 | await handler2.setAndAssertSize({ width: 321, height: 456 }); 107 | 108 | handler1.assertRenderCount(1); 109 | handler2.assertRenderCount(1); 110 | 111 | // Instance No. 1 should still be at the previous state. 112 | handler1.assertSize({ width: 100, height: 200 }); 113 | }); 114 | 115 | it("should handle custom refs", async () => { 116 | const Test: FunctionComponent = ({ 117 | resolveHandler, 118 | }) => { 119 | const ref = useRef(null); 120 | const { width, height } = useResizeObserver({ ref }); 121 | const motionSizeRef = useRef({ width, height }); 122 | const currentSizeRef = useRef<{ 123 | width: number | undefined; 124 | height: number | undefined; 125 | }>({ width: undefined, height: undefined }); 126 | 127 | currentSizeRef.current.height = height.get(); 128 | currentSizeRef.current.width = width.get(); 129 | 130 | useEffect(() => { 131 | resolveHandler( 132 | createComponentHandler({ currentSizeRef, motionSizeRef }) 133 | ); 134 | }, []); 135 | 136 | return ( 137 |
138 | {width.get()}x{height.get()} 139 |
140 | ); 141 | }; 142 | 143 | const handler = await render(Test); 144 | 145 | // Default 146 | handler.assertDefaultSize(); 147 | 148 | // Actual measurement 149 | await delay(50); 150 | handler.assertSize({ width: 100, height: 200 }); 151 | }); 152 | 153 | it("should be able to reuse the same ref to measure different elements", async () => { 154 | let switchRefs = (): void => { 155 | throw new Error(`"switchRefs" should've been implemented by now.`); 156 | }; 157 | const Test = ({ resolveHandler }: { resolveHandler: HandlerReceiver }) => { 158 | const ref1 = useRef(null); 159 | const ref2 = useRef(null); 160 | const [stateRef, setStateRef] = useState>(ref1); // Measures ref1 first 161 | const sizeRef = useRef(null); 162 | const { width, height } = useResizeObserver({ ref: stateRef }); 163 | const motionSizeRef = useRef({ width, height }); 164 | const currentSizeRef = useRef({ 165 | width: undefined, 166 | height: undefined, 167 | }); 168 | currentSizeRef.current.width = width.get(); 169 | currentSizeRef.current.height = height.get(); 170 | 171 | React.useLayoutEffect(() => { 172 | return width.onChange((v) => { 173 | const text = sizeRef.current; 174 | if (text) { 175 | text.textContent = `${width.get()}x${height.get()}`; 176 | } 177 | }); 178 | }); 179 | 180 | React.useLayoutEffect(() => { 181 | return height.onChange((v) => { 182 | const text = sizeRef.current; 183 | if (text) { 184 | text.textContent = `${width.get()}x${height.get()}`; 185 | } 186 | }); 187 | }); 188 | 189 | useEffect(() => { 190 | // Measures the second div on demand 191 | switchRefs = () => setStateRef(ref2); 192 | 193 | resolveHandler( 194 | createComponentHandler({ currentSizeRef, motionSizeRef }) 195 | ); 196 | }, []); 197 | 198 | return ( 199 | <> 200 |
201 | {width.get()}x{height.get()} 202 |
203 |
204 |
205 | 206 | ); 207 | }; 208 | 209 | const handler = await render(Test); 210 | 211 | // Default 212 | handler.assertDefaultSize(); 213 | 214 | // Div 1 measurement 215 | await delay(50); 216 | handler.assertSize({ width: 100, height: 200 }); 217 | 218 | // Div 2 measurement 219 | switchRefs(); 220 | await delay(50); 221 | handler.assertSize({ width: 150, height: 250 }); 222 | }); 223 | 224 | it("should be able to render without mock defaults", async () => { 225 | const handler = await render(Observed); 226 | 227 | // Default values should be undefined 228 | handler.assertDefaultSize(); 229 | 230 | handler.setSize({ width: 100, height: 100 }); 231 | await delay(50); 232 | handler.assertSize({ width: 100, height: 100 }); 233 | }); 234 | 235 | it("should not trigger unnecessary renders with the same width or height", async () => { 236 | const handler = await render(Observed); 237 | 238 | handler.assertDefaultSize(); 239 | 240 | // Default render + first measurement 241 | await delay(50); 242 | handler.assertRenderCount(1); 243 | 244 | handler.setSize({ width: 100, height: 102 }); 245 | await delay(50); 246 | handler.assertSize({ width: 100, height: 102 }); 247 | handler.assertRenderCount(1); 248 | 249 | // Shouldn't trigger on subpixel values that are rounded to be the same as the 250 | // previous size 251 | handler.setSize({ width: 100.4, height: 102.4 }); 252 | await delay(50); 253 | handler.assertSize({ width: 100, height: 102 }); 254 | handler.assertRenderCount(1); 255 | }); 256 | 257 | it("should keep the same response instance between renders if nothing changed", async () => { 258 | let assertSameInstance = (): void => { 259 | throw new Error( 260 | `"assertSameInstance" should've been implemented by now.` 261 | ); 262 | }; 263 | 264 | const Test: FunctionComponent = ({ 265 | resolveHandler, 266 | }) => { 267 | const previousResponseRef = useRef< 268 | | ({ 269 | ref: RefObject; 270 | } & MotionSize) 271 | | null 272 | >(null); 273 | const response = useResizeObserver(); 274 | const [state, setState] = useState(false); 275 | 276 | const sameInstance = previousResponseRef.current === response; 277 | previousResponseRef.current = response; 278 | 279 | useEffect(() => { 280 | if (response.width && response.height) { 281 | // Triggering an extra render once the hook properly measured the element size once. 282 | // This'll allow us to see if the hook keeps the same response object or not. 283 | setState(true); 284 | } 285 | }, [response]); 286 | 287 | useEffect(() => { 288 | if (!state) { 289 | return; 290 | } 291 | 292 | assertSameInstance = () => { 293 | expect(sameInstance).toBe(true); 294 | }; 295 | 296 | // Everything is set up, ready for assertions 297 | resolveHandler({}); 298 | }, [state]); 299 | 300 | return
{String(sameInstance)}
; 301 | }; 302 | 303 | await render(Test); 304 | 305 | assertSameInstance(); 306 | }); 307 | 308 | it("should ignore invalid custom refs", async () => { 309 | const Test: FunctionComponent = ({ 310 | resolveHandler, 311 | }) => { 312 | // Passing in an invalid custom ref. 313 | // Same should be work if "null" or something similar gets passed in. 314 | const { width, height } = useResizeObserver({ 315 | ref: {} as RefObject, 316 | }); 317 | const motionSizeRef = useRef({ width, height }); 318 | const currentSizeRef = useRef({} as ObservedSize); 319 | currentSizeRef.current.width = width.get(); 320 | currentSizeRef.current.height = height.get(); 321 | 322 | useEffect(() => { 323 | resolveHandler( 324 | createComponentHandler({ currentSizeRef, motionSizeRef }) 325 | ); 326 | }, []); 327 | 328 | const ref = React.createRef(); 329 | 330 | React.useLayoutEffect(() => { 331 | return width.onChange((v) => { 332 | const text = ref.current; 333 | if (text) { 334 | text.textContent = `${width.get()}x${height.get()}`; 335 | } 336 | }); 337 | }); 338 | 339 | React.useLayoutEffect(() => { 340 | return height.onChange((v) => { 341 | const text = ref.current; 342 | if (text) { 343 | text.textContent = `${width.get()}x${height.get()}`; 344 | } 345 | }); 346 | }); 347 | 348 | return ( 349 |
350 | {width.get()}x{height.get()} 351 |
352 | ); 353 | }; 354 | 355 | const handler = await render(Test); 356 | 357 | // Since no refs were passed in with an element to be measured, the hook should 358 | // stay on the defaults 359 | await delay(50); 360 | handler.assertDefaultSize(); 361 | }); 362 | 363 | it("should work with the polyfilled version", async () => { 364 | const Test: FunctionComponent = ({ 365 | resolveHandler, 366 | }) => { 367 | const { ref, width, height } = useResizeObserverPolyfilled< 368 | HTMLDivElement 369 | >(); 370 | const motionSizeRef = useRef({ width, height }); 371 | const currentSizeRef = useRef({} as ObservedSize); 372 | 373 | currentSizeRef.current.width = width.get(); 374 | currentSizeRef.current.height = height.get(); 375 | 376 | useEffect(() => { 377 | resolveHandler( 378 | createComponentHandler({ currentSizeRef, motionSizeRef }) 379 | ); 380 | }, []); 381 | 382 | React.useLayoutEffect(() => { 383 | return width.onChange((v) => { 384 | const text = ref.current; 385 | if (text) { 386 | text.textContent = `${width.get()}x${height.get()}`; 387 | } 388 | }); 389 | }); 390 | 391 | React.useLayoutEffect(() => { 392 | return height.onChange((v) => { 393 | const text = ref.current; 394 | if (text) { 395 | text.textContent = `${width.get()}x${height.get()}`; 396 | } 397 | }); 398 | }); 399 | 400 | return ( 401 |
402 | {width.get()}x{height.get()} 403 |
404 | ); 405 | }; 406 | 407 | const { assertSize } = await render(Test); 408 | 409 | await delay(50); 410 | assertSize({ width: 50, height: 40 }); 411 | }); 412 | 413 | it("should be able to work with onResize instead of rendering the values", async () => { 414 | const observations: ObservedSize[] = []; 415 | const handler = await render( 416 | Observed, 417 | {}, 418 | { onResize: (size: ObservedSize) => observations.push(size) } 419 | ); 420 | 421 | handler.setSize({ width: 100, height: 200 }); 422 | await delay(50); 423 | handler.setSize({ width: 101, height: 201 }); 424 | await delay(50); 425 | 426 | // Should stay at default as width/height is not passed to the hook response 427 | // when an onResize callback is given 428 | handler.assertDefaultSize(); 429 | 430 | expect(observations.length).toBe(2); 431 | expect(observations[0]).toEqual({ width: 100, height: 200 }); 432 | expect(observations[1]).toEqual({ width: 101, height: 201 }); 433 | 434 | // Should render once on mount only 435 | handler.assertRenderCount(1); 436 | }); 437 | 438 | it("should handle if the onResize handler changes properly with the correct render counts", async () => { 439 | let changeOnResizeHandler: (fn: Function) => void = () => {}; 440 | const Test: FunctionComponent = ({ 441 | resolveHandler, 442 | ...props 443 | }) => { 444 | const [onResizeHandler, setOnResizeHandler] = useState(() => () => {}); 445 | 446 | changeOnResizeHandler = (handler) => setOnResizeHandler(() => handler); 447 | 448 | return ( 449 | 454 | ); 455 | }; 456 | 457 | const { assertRenderCount, setSize } = await render(Test, { 458 | waitForFirstMeasurement: true, 459 | }); 460 | 461 | // Since `onResize` is used, no extra renders should've been triggered at this 462 | // point. (As opposed to the defaults where the hook would trigger a render 463 | // with the first measurement.) 464 | assertRenderCount(1); 465 | 466 | const observations1: ObservedSize[] = []; 467 | const observations2: ObservedSize[] = []; 468 | 469 | // Establishing a default, which'll be measured when the resize handler is set. 470 | setSize({ width: 1, height: 1 }); 471 | await delay(50); 472 | 473 | assertRenderCount(1); 474 | 475 | changeOnResizeHandler((size: ObservedSize) => observations1.push(size)); 476 | await delay(50); 477 | setSize({ width: 1, height: 2 }); 478 | await delay(50); 479 | setSize({ width: 3, height: 4 }); 480 | 481 | assertRenderCount(2); 482 | 483 | await delay(50); 484 | changeOnResizeHandler((size: ObservedSize) => observations2.push(size)); 485 | await delay(50); 486 | setSize({ width: 5, height: 6 }); 487 | await delay(50); 488 | setSize({ width: 7, height: 8 }); 489 | await delay(50); 490 | 491 | assertRenderCount(3); 492 | 493 | expect(observations1.length).toBe(2); 494 | expect(observations1[0]).toEqual({ width: 1, height: 2 }); 495 | expect(observations1[1]).toEqual({ width: 3, height: 4 }); 496 | 497 | expect(observations2.length).toBe(2); 498 | expect(observations2[0]).toEqual({ width: 5, height: 6 }); 499 | expect(observations2[1]).toEqual({ width: 7, height: 8 }); 500 | }); 501 | }); 502 | -------------------------------------------------------------------------------- /tests/ssr.test.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import React from "react"; 3 | // opting out from ts checks 4 | const Test = require("./ssr/Test"); 5 | import delay from "delay"; 6 | 7 | // This is replaced with the "server-generated" string before the tests are run. 8 | const html = `
1x2
`; 9 | 10 | describe("SSR", () => { 11 | it("should render with the defaults first, then hydrate properly", async () => { 12 | document.body.insertAdjacentHTML( 13 | "afterbegin", 14 | `
${html}
` 15 | ); 16 | 17 | const app = document.getElementById("app"); 18 | if (app === null) { 19 | throw new Error("#app not found"); 20 | } 21 | 22 | ReactDOM.hydrate(, app); 23 | 24 | expect(app.textContent).toBe(`1x2`); 25 | 26 | // For some reason headless Firefox takes a bit long here sometimes. 27 | await delay(100); 28 | 29 | expect(app.textContent).toBe(`100x200`); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/ssr/Test.js: -------------------------------------------------------------------------------- 1 | // For simplicity, this file is not in TS so that the node generation script can be simpler. 2 | const React = require("react"); 3 | const baseuseMotionResizeObserver = require("../../"); 4 | 5 | // I couldn't be bothered to use es6 for the node script, so I ended up with this... 6 | const useMotionResizeObserver = 7 | baseuseMotionResizeObserver.default || baseuseMotionResizeObserver; 8 | 9 | module.exports = function Test() { 10 | const { ref, width, height } = useMotionResizeObserver({ 11 | initial: { width: 1, height: 2 }, 12 | }); 13 | 14 | React.useLayoutEffect(() => { 15 | return width.onChange((v) => { 16 | const text = ref.current; 17 | text.textContent = `${width.get()}x${height.get()}`; 18 | }); 19 | }); 20 | 21 | React.useLayoutEffect(() => { 22 | return height.onChange((v) => { 23 | const text = ref.current; 24 | text.textContent = `${width.get()}x${height.get()}`; 25 | }); 26 | }); 27 | 28 | return React.createElement( 29 | "div", 30 | { ref, style: { width: 100, height: 200 } }, 31 | `${width.get()}x${height.get()}` 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /tests/ssr/create-ssr-test.js: -------------------------------------------------------------------------------- 1 | const React = require("react"); 2 | const ReactDOMServer = require("react-dom/server"); 3 | const Test = require("./Test"); 4 | const fs = require("fs"); 5 | const path = require("path"); 6 | 7 | const testString = fs.readFileSync( 8 | path.join(__dirname, "ssr.template.tsx"), 9 | "utf8" 10 | ); 11 | const html = ReactDOMServer.renderToString(React.createElement(Test)); 12 | 13 | fs.writeFileSync( 14 | path.join(__dirname, "../ssr.test.tsx"), 15 | testString.replace("<% GENERATED-HTML %>", html) 16 | ); 17 | -------------------------------------------------------------------------------- /tests/ssr/ssr.template.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import React from "react"; 3 | // opting out from ts checks 4 | const Test = require("./ssr/Test"); 5 | import delay from "delay"; 6 | 7 | // This is replaced with the "server-generated" string before the tests are run. 8 | const html = `<% GENERATED-HTML %>`; 9 | 10 | describe("SSR", () => { 11 | it("should render with the defaults first, then hydrate properly", async () => { 12 | document.body.insertAdjacentHTML( 13 | "afterbegin", 14 | `
${html}
` 15 | ); 16 | 17 | const app = document.getElementById("app"); 18 | if (app === null) { 19 | throw new Error("#app not found"); 20 | } 21 | 22 | ReactDOM.hydrate(, app); 23 | 24 | expect(app.textContent).toBe(`1x2`); 25 | 26 | // For some reason headless Firefox takes a bit long here sometimes. 27 | await delay(100); 28 | 29 | expect(app.textContent).toBe(`100x200`); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/testing-lib.tsx: -------------------------------------------------------------------------------- 1 | // Tests written with react testing library 2 | import React, { useRef, useEffect, useState } from "react"; 3 | import useMotionResizeObserver from "../"; 4 | import { render, cleanup, RenderResult } from "@testing-library/react"; 5 | import { ObservedSize } from "./utils"; 6 | import delay from "delay"; 7 | 8 | afterEach(() => { 9 | cleanup(); 10 | }); 11 | 12 | type ComponentController = { 13 | setSize: (width: number, height: number) => void; 14 | getRenderCount: () => number; 15 | getWidth: () => number | undefined; 16 | getHeight: () => number | undefined; 17 | triggerRender: () => void; 18 | switchToExplicitRef: () => void; 19 | }; 20 | 21 | type TestProps = { 22 | onResize?: (size: ObservedSize) => void; 23 | resolveController: (controller: ComponentController) => void; 24 | }; 25 | 26 | const Test = ({ onResize, resolveController }: TestProps) => { 27 | const [, setRenderTrigger] = useState(false); 28 | const [useExplicitRef, setUseExplicitRef] = useState(false); 29 | const explicitRef = useRef(null); 30 | const { ref, width, height } = useMotionResizeObserver({ 31 | // We intentionally create a new function instance here if onResize is given. 32 | // The hook is supposed to handle it and not recreate ResizeObserver instances on each render for example. 33 | onResize: onResize ? (size: ObservedSize) => onResize(size) : undefined, 34 | ...(useExplicitRef ? { ref: explicitRef } : {}), 35 | }); 36 | const controllerStateRef = useRef<{ renderCount: number } & ObservedSize>({ 37 | renderCount: 0, 38 | width: undefined, 39 | height: undefined, 40 | }); 41 | 42 | controllerStateRef.current.renderCount++; 43 | controllerStateRef.current.width = width.get(); 44 | controllerStateRef.current.height = height.get(); 45 | 46 | React.useLayoutEffect(() => 47 | width.onChange((v) => (controllerStateRef.current.width = v)) 48 | ); 49 | 50 | React.useLayoutEffect(() => 51 | height.onChange((v) => (controllerStateRef.current.height = v)) 52 | ); 53 | 54 | useEffect(() => { 55 | resolveController({ 56 | setSize: (width: number, height: number) => { 57 | if (!ref.current) { 58 | throw new Error(`Expected "ref.current" to be set.`); 59 | } 60 | 61 | ref.current.style.width = `${width}px`; 62 | ref.current.style.height = `${height}px`; 63 | }, 64 | getRenderCount: () => controllerStateRef.current.renderCount, 65 | getWidth: () => width.get(), 66 | getHeight: () => height.get(), 67 | triggerRender: () => setRenderTrigger((value) => !value), 68 | switchToExplicitRef: () => setUseExplicitRef(true), 69 | }); 70 | }, []); 71 | 72 | return
; 73 | }; 74 | 75 | const awaitNextFrame = () => 76 | new Promise((resolve) => setTimeout(resolve, 1000 / 60)); 77 | 78 | const renderTest = ( 79 | props: Omit = {} 80 | ): Promise<[ComponentController, RenderResult]> => 81 | new Promise((resolve) => { 82 | const tools = render( 83 | resolve([controller, tools])} 86 | > 87 | ); 88 | }); 89 | 90 | describe("Testing Lib: Basics", () => { 91 | it("should measure the right sizes", async () => { 92 | const [controller] = await renderTest(); 93 | 94 | // Default response on the first render before an actual measurement took place 95 | expect(controller.getWidth()).toBe(0); 96 | expect(controller.getHeight()).toBe(0); 97 | expect(controller.getRenderCount()).toBe(1); 98 | 99 | // Should react to component size changes. 100 | controller.setSize(100, 200); 101 | await awaitNextFrame(); 102 | expect(controller.getWidth()).toBe(100); 103 | expect(controller.getHeight()).toBe(200); 104 | expect(controller.getRenderCount()).toBe(1); 105 | }); 106 | }); 107 | 108 | describe("Testing Lib: Resize Observer Instance Counting Block", () => { 109 | let resizeObserverInstanceCount = 0; 110 | let resizeObserverObserveCount = 0; 111 | let resizeObserverUnobserveCount = 0; 112 | const NativeResizeObserver = (window as any).ResizeObserver; 113 | 114 | beforeAll(() => { 115 | (window as any).ResizeObserver = function PatchedResizeObserver( 116 | cb: Function 117 | ) { 118 | resizeObserverInstanceCount++; 119 | 120 | const ro = new NativeResizeObserver(cb) as ResizeObserver; 121 | 122 | const mock = { 123 | observe: (element: Element) => { 124 | resizeObserverObserveCount++; 125 | return ro.observe(element); 126 | }, 127 | unobserve: (element: Element) => { 128 | resizeObserverUnobserveCount++; 129 | return ro.unobserve(element); 130 | }, 131 | }; 132 | 133 | return mock; 134 | }; 135 | }); 136 | 137 | beforeEach(() => { 138 | resizeObserverInstanceCount = 0; 139 | resizeObserverObserveCount = 0; 140 | resizeObserverUnobserveCount = 0; 141 | }); 142 | 143 | afterAll(() => { 144 | // Try catches fixes a Firefox issue on Travis: 145 | // https://travis-ci.org/github/ZeeCoder/use-resize-observer/builds/677364283 146 | try { 147 | (window as any).ResizeObserver = NativeResizeObserver; 148 | } catch (error) { 149 | // it's fine 150 | } 151 | }); 152 | 153 | it("should use a single ResizeObserver instance even if the onResize callback is not memoised", async () => { 154 | const [controller] = await renderTest({ 155 | // This is only here so that each render passes a different callback 156 | // instance through to the hook. 157 | onResize: (size) => {}, 158 | }); 159 | 160 | await awaitNextFrame(); 161 | 162 | controller.triggerRender(); 163 | 164 | await awaitNextFrame(); 165 | 166 | // Different onResize instances used to trigger the hook's internal useEffect, 167 | // resulting in the hook using a new ResizeObserver instance on each render 168 | // regardless of what triggered it. 169 | expect(resizeObserverInstanceCount).toBe(1); 170 | expect(resizeObserverObserveCount).toBe(1); 171 | expect(resizeObserverUnobserveCount).toBe(0); 172 | }); 173 | 174 | it("should not reinstantiate if the hook is the same but the observed element changes", async () => { 175 | const [controller] = await renderTest(); 176 | 177 | // Default behaviour on initial mount with the explicit ref 178 | expect(resizeObserverInstanceCount).toBe(1); 179 | expect(resizeObserverObserveCount).toBe(1); 180 | expect(resizeObserverUnobserveCount).toBe(0); 181 | 182 | // Switching to a different ref / element causes the hook to unobserve the 183 | // previous element, and observe the new one, but it should not recreate the 184 | // ResizeObserver instance. 185 | 186 | // The waits here are added to replicate, and address an issue with travis 187 | // running Firefox in headless mode: 188 | // https://travis-ci.org/github/ZeeCoder/use-resize-observer/builds/677375509 189 | await awaitNextFrame(); 190 | controller.switchToExplicitRef(); 191 | await delay(1000); 192 | expect(resizeObserverInstanceCount).toBe(1); 193 | expect(resizeObserverObserveCount).toBe(2); 194 | expect(resizeObserverUnobserveCount).toBe(1); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es6", 4 | "target": "es5", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "outDir": "dist", 9 | "jsx": "react", 10 | "noEmit": true 11 | }, 12 | "include": [".", "../src"] 13 | } 14 | -------------------------------------------------------------------------------- /tests/utils/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, RefObject, FunctionComponent } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import useMotionResizeObserver from "../.."; 4 | import delay from "delay"; 5 | import { MotionValue, motion } from "framer-motion"; 6 | 7 | export type Size = { 8 | width: number; 9 | height: number; 10 | }; 11 | 12 | export type MotionSize = { 13 | width: MotionValue; 14 | height: MotionValue; 15 | }; 16 | 17 | export type ObservedSize = { 18 | width: number | undefined; 19 | height: number | undefined; 20 | }; 21 | 22 | type BaseComponentHandler = { 23 | assertSize: (size: ObservedSize) => void; 24 | assertDefaultSize: () => void; 25 | }; 26 | type SizingComponentHandler = { 27 | setSize: (size: Size | ObservedSize) => void; 28 | setAndAssertSize: (size: Size | ObservedSize) => void; 29 | }; 30 | type CountingComponentHandler = { 31 | assertRenderCount: (count: number) => void; 32 | }; 33 | 34 | export type ComponentHandler = BaseComponentHandler & 35 | SizingComponentHandler & 36 | CountingComponentHandler; 37 | 38 | export function createComponentHandler(opts: { 39 | motionSizeRef: RefObject; 40 | currentSizeRef: RefObject; 41 | }): BaseComponentHandler; 42 | export function createComponentHandler(opts: { 43 | motionSizeRef: RefObject; 44 | currentSizeRef: RefObject; 45 | measuredElementRef: RefObject; 46 | }): BaseComponentHandler & SizingComponentHandler; 47 | export function createComponentHandler(opts: { 48 | motionSizeRef: RefObject; 49 | currentSizeRef: RefObject; 50 | renderCountRef: RefObject; 51 | }): BaseComponentHandler & CountingComponentHandler; 52 | export function createComponentHandler(opts: { 53 | motionSizeRef: RefObject; 54 | currentSizeRef: RefObject; 55 | measuredElementRef: RefObject; 56 | renderCountRef: RefObject; 57 | }): ComponentHandler; 58 | export function createComponentHandler({ 59 | currentSizeRef, 60 | motionSizeRef, 61 | measuredElementRef, 62 | renderCountRef, 63 | }: { 64 | motionSizeRef: RefObject; 65 | currentSizeRef: RefObject; 66 | measuredElementRef?: RefObject; 67 | renderCountRef?: RefObject; 68 | }): BaseComponentHandler { 69 | let handler = { 70 | assertSize: function ({ width, height }: ObservedSize) { 71 | if (currentSizeRef.current === null) { 72 | throw new Error(`currentSizeRef.current is not set.`); 73 | } 74 | 75 | if (motionSizeRef.current === null) { 76 | throw new Error(`motionSizeRef.current is not set.`); 77 | } 78 | 79 | expect(motionSizeRef.current.width.get()).toBe(width || 0); 80 | expect(motionSizeRef.current.height.get()).toBe(height || 0); 81 | }, 82 | assertDefaultSize: function () { 83 | return this.assertSize({ width: 0, height: 0 }); 84 | }, 85 | } as ComponentHandler; 86 | 87 | if (measuredElementRef) { 88 | handler.setSize = ({ width, height }) => { 89 | if (measuredElementRef.current === null) { 90 | throw new Error(`measuredElementRef.current is not set.`); 91 | } 92 | 93 | measuredElementRef.current.style.width = `${width}px`; 94 | measuredElementRef.current.style.height = `${height}px`; 95 | }; 96 | handler.setAndAssertSize = async (size) => { 97 | handler.setSize(size); 98 | await delay(50); 99 | handler.assertSize(size); 100 | }; 101 | } 102 | 103 | if (renderCountRef) { 104 | handler.assertRenderCount = (count) => { 105 | expect(renderCountRef.current).toBe(count); 106 | }; 107 | } 108 | 109 | return handler; 110 | } 111 | 112 | export type HandlerResolverComponentProps = { 113 | resolveHandler: HandlerReceiver; 114 | }; 115 | 116 | export type MultiHandlerResolverComponentProps = { 117 | resolveHandler: MultiHandlerReceiver; 118 | }; 119 | 120 | export const Observed: FunctionComponent< 121 | HandlerResolverComponentProps & { 122 | defaultWidth?: number; 123 | defaultHeight?: number; 124 | onResize?: (size: ObservedSize) => void; 125 | } 126 | > = ({ resolveHandler, defaultWidth, defaultHeight, onResize, ...props }) => { 127 | const renderCountRef = useRef(0); 128 | const hasDefaults = defaultWidth || defaultHeight; 129 | const { ref: measuredElementRef, width, height } = useMotionResizeObserver< 130 | HTMLDivElement 131 | >({ 132 | onResize, 133 | ...(hasDefaults 134 | ? { 135 | initial: { 136 | width: defaultWidth as number, 137 | height: defaultHeight as number, 138 | }, 139 | } 140 | : {}), 141 | }); 142 | const currentSizeRef = useRef({ 143 | width: undefined, 144 | height: undefined, 145 | }); 146 | 147 | const motionSizeRef = useRef({ width, height }); 148 | 149 | currentSizeRef.current.width = width.get(); 150 | currentSizeRef.current.height = height.get(); 151 | renderCountRef.current++; 152 | 153 | React.useLayoutEffect(() => { 154 | return width.onChange((v) => { 155 | const text = rTextContent.current; 156 | if (text) { 157 | text.innerText = `${v}x${height.get()}`; 158 | } 159 | currentSizeRef.current = { 160 | ...currentSizeRef.current, 161 | width: v, 162 | }; 163 | }); 164 | }); 165 | 166 | React.useLayoutEffect(() => { 167 | return height.onChange((v) => { 168 | const text = rTextContent.current; 169 | if (text) { 170 | text.innerText = `${width.get()}x${v}`; 171 | } 172 | currentSizeRef.current = { 173 | ...currentSizeRef.current, 174 | height: v, 175 | }; 176 | }); 177 | }); 178 | 179 | useEffect(() => { 180 | if (!resolveHandler) { 181 | return; 182 | } 183 | 184 | resolveHandler( 185 | createComponentHandler({ 186 | motionSizeRef, 187 | currentSizeRef, 188 | measuredElementRef, 189 | renderCountRef, 190 | }) 191 | ); 192 | }, []); 193 | 194 | const rTextContent = React.useRef(null); 195 | 196 | return ( 197 | 211 | 212 | {width.get()}x{height.get()} 213 | 214 |
215 | Render Count: {renderCountRef.current} 216 |
217 |
218 | ); 219 | }; 220 | 221 | export type HandlerReceiver = >( 222 | handler: T 223 | ) => void; 224 | export type MultiHandlerReceiver = >( 225 | handler: T[] 226 | ) => void; 227 | 228 | let appRoot: HTMLDivElement | null = null; 229 | 230 | export function render( 231 | TestComponent: FunctionComponent, 232 | opts?: { waitForFirstMeasurement?: boolean }, 233 | props?: {} 234 | ): Promise; 235 | export function render( 236 | TestComponent: FunctionComponent, 237 | opts?: { waitForFirstMeasurement?: boolean }, 238 | props?: {} 239 | ): Promise; 240 | export function render( 241 | TestComponent: 242 | | FunctionComponent 243 | | FunctionComponent, 244 | { 245 | waitForFirstMeasurement = false, 246 | }: { waitForFirstMeasurement?: boolean } = {}, 247 | props?: {} 248 | ) { 249 | return new Promise((resolve) => { 250 | async function resolveHandler>( 251 | handler: T | T[] 252 | ): Promise { 253 | if (waitForFirstMeasurement) { 254 | await delay(50); 255 | } 256 | 257 | resolve(handler); 258 | } 259 | 260 | if (!appRoot) { 261 | appRoot = document.createElement("div"); 262 | appRoot.id = "app"; 263 | document.body.appendChild(appRoot); 264 | } 265 | 266 | // Clean out previous renders 267 | ReactDOM.render(
, appRoot); 268 | 269 | ReactDOM.render( 270 | , 271 | appRoot 272 | ); 273 | }); 274 | } 275 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "outDir": "dist", 9 | "declaration": true, 10 | "emitDeclarationOnly": true 11 | }, 12 | "include": ["src"] 13 | } 14 | --------------------------------------------------------------------------------