├── tests ├── .gitignore ├── @types │ ├── jest │ │ ├── jasmine │ │ │ └── index.d.ts │ │ └── karma-jasmine │ │ │ └── index.d.ts │ └── karma │ │ └── jest │ │ └── index.d.ts ├── ie │ ├── basic.tsx │ ├── testing-lib.tsx │ └── polyfilled.tsx ├── utils │ ├── delay.ts │ ├── useRenderTrigger.ts │ ├── index.tsx │ ├── ie-polyfills.ts │ ├── awaitNextFrame.ts │ ├── useMergedCallbackRef.ts │ └── createController.ts ├── tsconfig.json ├── ssr │ ├── create-ssr-test.js │ ├── Test.js │ └── ssr.template.tsx ├── ssr.test.tsx ├── basic.tsx └── testing-lib.tsx ├── .github ├── FUNDING.yml └── workflows │ ├── size-limit.yml │ ├── release.yml │ └── testing.yml ├── media └── Logo.png ├── .husky └── pre-commit ├── .babelrc ├── .gitignore ├── .editorconfig ├── jest.config.js ├── browserslist ├── .size-limit.json ├── .eslintrc.js ├── .releaserc ├── tsconfig.json ├── LICENSE ├── rollup.config.js ├── CONTRIBUTING.md ├── src ├── utils │ ├── useResolvedElement.ts │ ├── extractSize.ts │ └── useResolvedElement.test.tsx └── index.ts ├── CHANGELOG.pre-7.0.0.md ├── package.json ├── karma.conf.js ├── CHANGELOG.md └── README.md /tests/.gitignore: -------------------------------------------------------------------------------- 1 | ssr.test.js 2 | -------------------------------------------------------------------------------- /tests/@types/jest/jasmine/index.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/@types/karma/jest/index.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/@types/jest/karma-jasmine/index.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ZeeCoder 2 | patreon: zeecoder 3 | -------------------------------------------------------------------------------- /tests/ie/basic.tsx: -------------------------------------------------------------------------------- 1 | import "../utils/ie-polyfills"; 2 | import "../basic"; 3 | -------------------------------------------------------------------------------- /media/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeeCoder/use-resize-observer/HEAD/media/Logo.png -------------------------------------------------------------------------------- /tests/ie/testing-lib.tsx: -------------------------------------------------------------------------------- 1 | import "../utils/ie-polyfills"; 2 | import "../testing-lib"; 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no lint-staged 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/typescript", ["@babel/preset-env", { "loose": true }]] 3 | } 4 | -------------------------------------------------------------------------------- /tests/utils/delay.ts: -------------------------------------------------------------------------------- 1 | export default (time: number) => 2 | new Promise((resolve) => setTimeout(resolve, time)); 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | node_modules 3 | .idea 4 | .vscode 5 | yarn-error.log* 6 | dist 7 | /polyfilled.js 8 | /polyfilled.d.ts 9 | local.log 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "jsdom", 5 | testPathIgnorePatterns: ["/node_modules/", "/tests/"], 6 | }; 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "dist/bundle.esm.js", 4 | "limit": "648B", 5 | "gzip": true 6 | }, 7 | { 8 | "path": "dist/bundle.cjs.js", 9 | "limit": "625B", 10 | "gzip": true 11 | }, 12 | { 13 | "path": "polyfilled.js", 14 | "limit": "3384B", 15 | "gzip": true 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint", "react-hooks"], 5 | extends: [ 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | rules: { 10 | "@typescript-eslint/ban-ts-comment": "off", 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /tests/utils/useRenderTrigger.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import awaitNextFrame from "./awaitNextFrame"; 3 | 4 | export default function useRenderTrigger() { 5 | const [, setTrigger] = useState(false); 6 | 7 | return useCallback(async () => { 8 | setTrigger((val) => !val); 9 | await awaitNextFrame(); 10 | }, []); 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/size-limit.yml: -------------------------------------------------------------------------------- 1 | name: "Size Limit" 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - alpha 7 | jobs: 8 | size: 9 | runs-on: ubuntu-latest 10 | env: 11 | CI_JOB_NUMBER: 1 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: andresz1/size-limit-action@v1 15 | with: 16 | github_token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /tests/utils/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Creating an RO instance ahead of time as a "side effect" of using this module, to avoid affecting tests. 4 | export const supports = { 5 | borderBox: false, 6 | devicePixelContentBoxSize: false, 7 | }; 8 | new ResizeObserver((entries) => { 9 | supports.borderBox = Boolean(entries[0].borderBoxSize); 10 | supports.devicePixelContentBoxSize = Boolean( 11 | entries[0].devicePixelContentBoxSize 12 | ); 13 | }).observe(document.body); 14 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | ["@semantic-release/changelog", { 6 | "changelogFile": "CHANGELOG.md" 7 | }], 8 | ["@semantic-release/npm", { 9 | "tarballDir": "release" 10 | }], 11 | ["@semantic-release/github", { 12 | "assets": "release/*.tgz" 13 | }], 14 | ["@semantic-release/git", { 15 | "assets": ["CHANGELOG.md", "package.json"] 16 | }], 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /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 | "noImplicitAny": true, 12 | "jsx": "react", 13 | "typeRoots": [ 14 | "tests/@types/jest", "node_modules/@types"] 15 | }, 16 | "include": ["src"], 17 | "exclude": [ 18 | "src/**/*.test.tsx" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /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 | "declaration": false, 10 | "emitDeclarationOnly": false, 11 | "jsx": "react", 12 | "noEmit": true, 13 | "typeRoots": [ 14 | "@types/karma", "../node_modules/@types"] 15 | }, 16 | "include": [".", "../src"], 17 | "exclude": [ 18 | "../src/**/*.test.tsx" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /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/utils/ie-polyfills.ts: -------------------------------------------------------------------------------- 1 | import "react-app-polyfill/ie11"; 2 | import "react-app-polyfill/stable"; 3 | import { ResizeObserver as ROP } from "@juggle/resize-observer"; 4 | if (!window.ResizeObserver) { 5 | // @ts-ignore 6 | window.ResizeObserver = ROP; 7 | } 8 | if (!Object.entries) { 9 | // @ts-ignore 10 | Object.entries = function (obj) { 11 | var ownProps = Object.keys(obj), 12 | i = ownProps.length, 13 | resArray = new Array(i); // preallocate the Array 14 | while (i--) resArray[i] = [ownProps[i], obj[ownProps[i]]]; 15 | 16 | return resArray; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - alpha 7 | jobs: 8 | release: 9 | name: release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 14 16 | - name: Installing Dependencies 17 | run: yarn install --frozen-lockfile 18 | - name: Releasing 19 | run: npx semantic-release 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /tests/utils/awaitNextFrame.ts: -------------------------------------------------------------------------------- 1 | // @see https://stackoverflow.com/a/21825207/982092 2 | // @ts-ignore 3 | const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; 4 | // TODO instead of hardcoded values, we should wait with a timeout for sizes to 5 | // be reported whenever they're available. (rAF in a loop maybe) 6 | export default function awaitNextFrame() { 7 | return new Promise((resolve) => 8 | // Seems like that on IE with the RO polyfill we need to slow things down a bit 9 | // Also, 1000 / 60 did not seem to not be enough of a wait sometimes on modern browsers either. 10 | setTimeout(resolve, 1000 / (isIE11 ? 5 : 30)) 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /tests/utils/useMergedCallbackRef.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | 3 | /** 4 | * This hook allows you to intercept a RefCallback and receive the element in 5 | * another function as well. 6 | */ 7 | const useMergedCallbackRef = (...callbacks: Function[]) => { 8 | // Storing callbacks in a ref, so that we don't need to memoise them in 9 | // renders when using this hook. 10 | const callbacksRegistry = useRef(callbacks); 11 | 12 | useEffect(() => { 13 | callbacksRegistry.current = callbacks; 14 | }, [...callbacks]); 15 | 16 | return useCallback((element: any) => { 17 | callbacksRegistry.current.forEach((callback) => callback(element)); 18 | }, []); 19 | }; 20 | 21 | export default useMergedCallbackRef; 22 | -------------------------------------------------------------------------------- /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 baseUseResizeObserver = require("../../"); 4 | 5 | // I couldn't be bothered to use es6 for the node script, so I ended up with this... 6 | const useResizeObserver = 7 | baseUseResizeObserver.default || baseUseResizeObserver; 8 | 9 | module.exports = function Test() { 10 | // Pasting in our own ref here, as this used to cause issues with SSR: 11 | // @see https://github.com/ZeeCoder/use-resize-observer/issues/74 12 | const ref = React.useRef(null); 13 | const { width = 1, height = 2 } = useResizeObserver({ ref }); 14 | 15 | return React.createElement( 16 | "div", 17 | { ref, style: { width: 100, height: 200 } }, 18 | `${width}x${height}` 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /tests/ssr/ssr.template.tsx: -------------------------------------------------------------------------------- 1 | import { hydrateRoot } from "react-dom/client"; 2 | import React from "react"; 3 | import delay from "delay"; 4 | // opting out from ts checks 5 | const Test = require("./ssr/Test"); 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 | hydrateRoot(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.tsx: -------------------------------------------------------------------------------- 1 | import { hydrateRoot } from "react-dom/client"; 2 | import React from "react"; 3 | import delay from "delay"; 4 | // opting out from ts checks 5 | const Test = require("./ssr/Test"); 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 | hydrateRoot(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 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - alpha 7 | jobs: 8 | testing: 9 | runs-on: ubuntu-latest 10 | env: 11 | BS_ACCESS_KEY: ${{ secrets.BS_ACCESS_KEY }} 12 | BS_USERNAME: ${{ secrets.BS_USERNAME }} 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | - name: Set up Node 17 | uses: actions/setup-node@v3 18 | with: 19 | always-auth: true 20 | node-version: 14 21 | - name: Installing Dependencies 22 | run: yarn install --frozen-lockfile 23 | - name: Checking File Size 24 | run: yarn check:size 25 | - name: Checking Linting Rules 26 | run: yarn check:lint 27 | - name: Checking Types 28 | run: yarn check:types 29 | - name: Running Unit Tests 30 | run: yarn test:unit 31 | - name: Testing SSR 32 | run: yarn test:create:ssr 33 | - name: Testing in Modern Browsers (BrowserStack) 34 | run: yarn test:bs:modern 35 | - name: Testing in Legacy Browsers (BrowserStack) 36 | run: yarn test:bs:legacy 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2018 Viktor Hubert 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 | -------------------------------------------------------------------------------- /tests/ie/polyfilled.tsx: -------------------------------------------------------------------------------- 1 | import "react-app-polyfill/ie11"; 2 | import React from "react"; 3 | import useResizeObserver from "../../polyfilled"; 4 | import { ResizeObserver as ROP } from "@juggle/resize-observer"; 5 | import createController from "../utils/createController"; 6 | import { act, render } from "@testing-library/react"; 7 | import awaitNextFrame from "../utils/awaitNextFrame"; 8 | 9 | /** 10 | * This test ensures that the shipped polyfilled version actually works. 11 | * This has to run alone independent from other tests, otherwise the environment 12 | * may have been polyfilled already. 13 | */ 14 | describe("Polyfilled lib testing", () => { 15 | beforeAll(() => { 16 | // @ts-ignore 17 | delete window.ResizeObserver; 18 | }); 19 | 20 | afterAll(() => { 21 | if (!window.ResizeObserver) { 22 | // @ts-ignore 23 | window.ResizeObserver = ROP; 24 | } 25 | }); 26 | 27 | it("should work with the polyfilled version", async () => { 28 | const controller = createController(); 29 | const Test = () => { 30 | const response = useResizeObserver(); 31 | controller.reportMeasuredSize(response); 32 | 33 | return
; 34 | }; 35 | 36 | render(); 37 | 38 | await act(async () => { 39 | await awaitNextFrame(); 40 | }); 41 | 42 | controller.assertMeasuredSize({ width: 50, height: 40 }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "@rollup/plugin-babel"; 2 | import inject from "@rollup/plugin-inject"; 3 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 4 | 5 | const getConfig = ({ polyfill = false } = {}) => { 6 | const config = { 7 | input: "src/index.ts", 8 | output: [], 9 | plugins: [ 10 | nodeResolve({ 11 | extensions: [".ts"], 12 | }), 13 | babel({ 14 | extensions: ["ts"], 15 | // Seems like there's not really a difference in case of this lib, but 16 | // might worth reconsidering later to use "runtime". 17 | // @see https://github.com/rollup/plugins/tree/master/packages/babel#babelhelpers 18 | babelHelpers: "bundled", 19 | }), 20 | ], 21 | external: ["react"], 22 | }; 23 | 24 | if (polyfill) { 25 | config.output = [ 26 | { 27 | file: "polyfilled.js", 28 | format: "cjs", 29 | exports: "default", 30 | }, 31 | ]; 32 | config.external.push("@juggle/resize-observer"); 33 | config.plugins.push( 34 | inject({ 35 | ResizeObserver: ["@juggle/resize-observer", "ResizeObserver"], 36 | }) 37 | ); 38 | } else { 39 | config.output = [ 40 | { 41 | file: "dist/bundle.cjs.js", 42 | format: "cjs", 43 | exports: "default", 44 | }, 45 | { 46 | file: "dist/bundle.esm.js", 47 | format: "esm", 48 | }, 49 | ]; 50 | } 51 | 52 | return config; 53 | }; 54 | 55 | export default [getConfig(), getConfig({ polyfill: true })]; 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | When contributing to this project, please keep in mind the following: 4 | 5 | - The hook must remain as simple as possible. It's only a low-level "proxy" to a 6 | ResizeObserver instance aiming for correctness, which should not add or polyfill 7 | features on top. All that can be done by composing hooks. 8 | - All features must be covered with test(s). 9 | 10 | It's also best to first submit an issue, before creating a pull request so that 11 | the required feature can be discussed, as well as the actual implementation. 12 | 13 | ## Adding a New Feature 14 | 15 | - Open an issue, so that a discussion can take place, 16 | - Once discussed, add the changes to `src/index.ts` accordingly (make sure TS 17 | types are respected as well), 18 | - Add new test(s) to cover the new feature in: `test/testing-lib.tsx`. 19 | Ignore the other test files, as they're fairly old and much harder to add new 20 | ones to them compared to just using react testing lib. 21 | - Run all the tests to ensure there are no regressions: `yarn test`, 22 | 23 | ## Using Watch Modes While Developing 24 | 25 | There are other test-related scripts in package.json, to enhance the developer 26 | experience. 27 | While making changes you might want to watch the source files, and build them 28 | automatically, as well as having Karma run a (non-headless) Chrome instance 29 | every time a change was made. 30 | 31 | To do so: 32 | 33 | - Run `yarn src:watch` in a terminal tab 34 | - Run `yarn karma:watch` in another. 35 | 36 | Don't forget to run `yarn test` at the end once you're done with everything, to 37 | make sure the new code is tested for regressions. 38 | 39 | If you have a Browserstack account, then you can also run the tests in real browsers using the `test:bs:*` commands. 40 | Just make sure you have the following env variables set: `BS_USERNAME`, `BS_ACCESS_KEY`. 41 | -------------------------------------------------------------------------------- /tests/utils/createController.ts: -------------------------------------------------------------------------------- 1 | // Creates a shared object which can be used in the test function, as well as 2 | // within the test component. 3 | // Provides some often used functions as well. 4 | import awaitNextFrame from "./awaitNextFrame"; 5 | 6 | const setSizePlaceholder: SetSizeFunction = async (params: SizeParams) => {}; 7 | 8 | type SizeParams = { 9 | width?: number; 10 | height?: number; 11 | }; 12 | type SetSizeFunction = (params: SizeParams) => Promise; 13 | const doSetSize = async (element: HTMLElement | null, params: SizeParams) => { 14 | if (!element) { 15 | return; 16 | } 17 | 18 | if (params.width) { 19 | element.style.width = `${params.width}px`; 20 | } 21 | if (params.height) { 22 | element.style.height = `${params.height}px`; 23 | } 24 | 25 | // Returning a promise here to wait for the next "tick". 26 | // Useful when you want to check the effects of a size change 27 | await awaitNextFrame(); 28 | }; 29 | 30 | export default function createController() { 31 | let renderCount = 0; 32 | const incrementRenderCount = () => renderCount++; 33 | const assertRenderCount = (count: number) => expect(renderCount).toBe(count); 34 | 35 | let measuredWidth: number | undefined; 36 | let measuredHeight: number | undefined; 37 | const reportMeasuredSize = (params: SizeParams) => { 38 | if ( 39 | typeof params.width === "number" || 40 | typeof params.width === "undefined" 41 | ) { 42 | measuredWidth = params.width; 43 | } 44 | if ( 45 | typeof params.height === "number" || 46 | typeof params.height === "undefined" 47 | ) { 48 | measuredHeight = params.height; 49 | } 50 | }; 51 | const assertMeasuredSize = (params: SizeParams) => { 52 | expect(measuredWidth).toBe(params.width); 53 | expect(measuredHeight).toBe(params.height); 54 | }; 55 | 56 | const controller = { 57 | incrementRenderCount, 58 | assertRenderCount, 59 | reportMeasuredSize, 60 | assertMeasuredSize, 61 | setSize: setSizePlaceholder, 62 | provideSetSizeFunction: (ref: HTMLElement | null) => {}, // Placeholder to make TS happy 63 | triggerRender: async () => {}, 64 | }; 65 | 66 | // surely there's a better way to do this? 67 | controller.provideSetSizeFunction = (element: HTMLElement | null) => { 68 | controller.setSize = (params: SizeParams) => doSetSize(element, params); 69 | }; 70 | 71 | return controller; 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/useResolvedElement.ts: -------------------------------------------------------------------------------- 1 | import { RefCallback, RefObject, useCallback, useEffect, useRef } from "react"; 2 | 3 | type SubscriberCleanupFunction = () => void; 4 | type SubscriberResponse = SubscriberCleanupFunction | void; 5 | 6 | // This could've been more streamlined with internal state instead of abusing 7 | // refs to such extent, but then composing hooks and components could not opt out of unnecessary renders. 8 | export default function useResolvedElement( 9 | subscriber: (element: T) => SubscriberResponse, 10 | refOrElement?: T | RefObject | null 11 | ): RefCallback { 12 | const lastReportRef = useRef<{ 13 | element: T | null; 14 | subscriber: typeof subscriber; 15 | cleanup?: SubscriberResponse; 16 | } | null>(null); 17 | const refOrElementRef = useRef(null); 18 | refOrElementRef.current = refOrElement; 19 | const cbElementRef = useRef(null); 20 | 21 | // Calling re-evaluation after each render without using a dep array, 22 | // as the ref object's current value could've changed since the last render. 23 | useEffect(() => { 24 | evaluateSubscription(); 25 | }); 26 | 27 | const evaluateSubscription = useCallback(() => { 28 | const cbElement = cbElementRef.current; 29 | const refOrElement = refOrElementRef.current; 30 | // Ugly ternary. But smaller than an if-else block. 31 | const element: T | null = cbElement 32 | ? cbElement 33 | : refOrElement 34 | ? refOrElement instanceof Element 35 | ? refOrElement 36 | : refOrElement.current 37 | : null; 38 | 39 | if ( 40 | lastReportRef.current && 41 | lastReportRef.current.element === element && 42 | lastReportRef.current.subscriber === subscriber 43 | ) { 44 | return; 45 | } 46 | 47 | if (lastReportRef.current && lastReportRef.current.cleanup) { 48 | lastReportRef.current.cleanup(); 49 | } 50 | lastReportRef.current = { 51 | element, 52 | subscriber, 53 | // Only calling the subscriber, if there's an actual element to report. 54 | // Setting cleanup to undefined unless a subscriber returns one, as an existing cleanup function would've been just called. 55 | cleanup: element ? subscriber(element) : undefined, 56 | }; 57 | }, [subscriber]); 58 | 59 | // making sure we call the cleanup function on unmount 60 | useEffect(() => { 61 | return () => { 62 | if (lastReportRef.current && lastReportRef.current.cleanup) { 63 | lastReportRef.current.cleanup(); 64 | lastReportRef.current = null; 65 | } 66 | }; 67 | }, []); 68 | 69 | return useCallback( 70 | (element) => { 71 | cbElementRef.current = element; 72 | evaluateSubscription(); 73 | }, 74 | [evaluateSubscription] 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/extractSize.ts: -------------------------------------------------------------------------------- 1 | // We're only using the first element of the size sequences, until future versions of the spec solidify on how 2 | // exactly it'll be used for fragments in multi-column scenarios: 3 | // From the spec: 4 | // > The box size properties are exposed as FrozenArray in order to support elements that have multiple fragments, 5 | // > which occur in multi-column scenarios. However the current definitions of content rect and border box do not 6 | // > mention how those boxes are affected by multi-column layout. In this spec, there will only be a single 7 | // > ResizeObserverSize returned in the FrozenArray, which will correspond to the dimensions of the first column. 8 | // > A future version of this spec will extend the returned FrozenArray to contain the per-fragment size information. 9 | // (https://drafts.csswg.org/resize-observer/#resize-observer-entry-interface) 10 | // 11 | // Also, testing these new box options revealed that in both Chrome and FF everything is returned in the callback, 12 | // regardless of the "box" option. 13 | // The spec states the following on this: 14 | // > This does not have any impact on which box dimensions are returned to the defined callback when the event 15 | // > is fired, it solely defines which box the author wishes to observe layout changes on. 16 | // (https://drafts.csswg.org/resize-observer/#resize-observer-interface) 17 | // I'm not exactly clear on what this means, especially when you consider a later section stating the following: 18 | // > This section is non-normative. An author may desire to observe more than one CSS box. 19 | // > In this case, author will need to use multiple ResizeObservers. 20 | // (https://drafts.csswg.org/resize-observer/#resize-observer-interface) 21 | // Which is clearly not how current browser implementations behave, and seems to contradict the previous quote. 22 | // For this reason I decided to only return the requested size, 23 | // even though it seems we have access to results for all box types. 24 | // This also means that we get to keep the current api, being able to return a simple { width, height } pair, 25 | // regardless of box option. 26 | export default function extractSize( 27 | entry: ResizeObserverEntry, 28 | boxProp: "borderBoxSize" | "contentBoxSize" | "devicePixelContentBoxSize", 29 | sizeType: keyof ResizeObserverSize 30 | ): number | undefined { 31 | if (!entry[boxProp]) { 32 | if (boxProp === "contentBoxSize") { 33 | // The dimensions in `contentBoxSize` and `contentRect` are equivalent according to the spec. 34 | // See the 6th step in the description for the RO algorithm: 35 | // https://drafts.csswg.org/resize-observer/#create-and-populate-resizeobserverentry-h 36 | // > Set this.contentRect to logical this.contentBoxSize given target and observedBox of "content-box". 37 | // In real browser implementations of course these objects differ, but the width/height values should be equivalent. 38 | return entry.contentRect[sizeType === "inlineSize" ? "width" : "height"]; 39 | } 40 | 41 | return undefined; 42 | } 43 | 44 | // A couple bytes smaller than calling Array.isArray() and just as effective here. 45 | return entry[boxProp][0] 46 | ? entry[boxProp][0][sizeType] 47 | : // TS complains about this, because the RO entry type follows the spec and does not reflect Firefox's current 48 | // behaviour of returning objects instead of arrays for `borderBoxSize` and `contentBoxSize`. 49 | // @ts-ignore 50 | entry[boxProp][sizeType]; 51 | } 52 | -------------------------------------------------------------------------------- /CHANGELOG.pre-7.0.0.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 7.0.0-alpha.2 4 | 5 | - Added tests in real browsers with Browserstack, so that we ensure the lib 6 | works all the way back to IE11. 7 | - Switched to GitHub Actions 8 | 9 | ## 7.0.0-alpha.1 10 | 11 | - **[BREAKING]** The returned ref is now a RefCallback, not a ref object 12 | - **[BREAKING]** The returned ref will always be the same RefCallback. 13 | Previously when a custom ref object was passed, it was returned as well from 14 | the hook as "ref". 15 | - **[BREAKING]** Compared to 6.2.0-alpha.1 There's no `callbackRef` return value 16 | anymore. 17 | 18 | ## 6.2.0-alpha.1 19 | 20 | - Only instantiating a ResizeObserver instance if there's actually something to 21 | observe. This for example means that if you pass in `null` or undefined as the 22 | ref, or if neither the default ref or RefCallback returned from the hook are 23 | in use, then no ResizeObserver instance will get created until there's an 24 | actual element to observe. Resolves: #42 25 | - The hook now returns `callbackRef`, which can be used in place of the usual 26 | `ref`. Use this instead of a normal ref, when the observed component is 27 | mounted with a delay. Resolves: #43, #45 28 | - The `ref` option now accepts raw elements as well. 29 | - Handling custom refs (through options), the default ref and the RefCallback 30 | has been greatly refactored internally (into the `useResolvedElement` 31 | hook), to handle more edge cases with the way refs are handled. 32 | - Tests based on react testing library were refactored to make them much simpler 33 | and more approachable. 34 | - Fixed an error where in certain edge cases the hook tried to set state when 35 | its host component already unmounted. 36 | - Added [contributing guidelines](./CONTRIBUTING.md) 37 | - Overall bundle size increased a bit, due to the new features added. 38 | (With about ~150B or so.) 39 | 40 | ## 6.1.0 41 | 42 | - No changes, only publishing the next minor. 43 | 44 | ## 6.1.0-alpha.3 45 | 46 | - Fixed SSR rendering, and added a test to cover it. 47 | 48 | ## 6.1.0-alpha.2 49 | 50 | - ResizeObserver instances are no longer created unnecessarily when the onResize 51 | callback changes. (Fixes #32) 52 | - Written new tests in [react testing library](https://github.com/testing-library/react-testing-library). 53 | 54 | ## 6.1.0-alpha.1 55 | 56 | - Rewrote the source in TypeScript. (Feedback is welcome.) 57 | - Rewrote tests in TypeScript as well. (Ensures the types make sense.) 58 | - Added checks to ensure reasonable bundle size. 59 | 60 | ## 6.0.0 61 | 62 | - **[BREAKING]** Default width and height can now be set by declaring defaults 63 | for object destructuring, instead of having custom options for them. 64 | This means the following options were removed: `defaultWidth`, `defaultHeight`, 65 | `useDefaults`. 66 | - **[BREAKING]** Due to the above, the default width and height will be 67 | `undefined` instead of `1`. 68 | - Docs were updated to reflect the above changes. 69 | - Added an `onResize` handler that can report size changes instead of the default 70 | object return. (This also helps implementing a more performant throttle / 71 | debounce solution.) 72 | 73 | ## 5.0.0 74 | 75 | - **[BREAKING]** `#14` Removed the polyfill from the default builds, and shipping 76 | it instead as as separate module. 77 | - **[BREAKING]** `#21` Returning an object instead of an array, so that values not 78 | needed could be omitted. 79 | - `#18` Added missing copyright notice in the MIT license. 80 | - Improved ref handling: 81 | - `#16` You can now pass in your own ref 82 | - The same hook instance can now be reused with different refs 83 | - The hook will no longer break if the ref is not immediately filled. 84 | (Anything other than an object with a `.current` value of an `Element` will 85 | be ignored.) 86 | - Made defaults optional with the `useDefaults` option. 87 | - New `package.json` scripts to ease development 88 | - Added throttle and debounce guides to the readme 89 | - More tests 90 | 91 | ## 4.0.0 92 | 93 | - Added option to pass default width and height. Useful when using the lib with 94 | SSR. (Thanks [Simon Boudrias](https://github.com/SBoudrias) and 95 | [Fokke Zandbergen](https://github.com/FokkeZB)) 96 | - Dep upgrades 97 | - **[BREAKING]** Removed TS types. See: 98 | - https://github.com/ZeeCoder/use-resize-observer/issues/12 99 | - https://github.com/ZeeCoder/use-resize-observer/pull/13 100 | - https://github.com/ZeeCoder/use-resize-observer/pull/8 101 | 102 | ## 3.1.0 103 | 104 | - Added Typescript types 105 | 106 | ## 3.0.0 107 | 108 | - **[BREAKING]** Requires React 16.8.0 or above, which is the first non-alpha 109 | release that includes hooks 110 | 111 | ## 2.0.1 112 | 113 | - No real changes, testing travis deployment from master 114 | 115 | ## 2.0.0 116 | 117 | - **[BREAKING]** Returning a tuple and creating a ref object automatically 118 | - Using resize-observer-polyfill instead of resize-observer 119 | - Fixed an issue where resize observer would trigger changes endlessly 120 | - Added tests using Karma 121 | 122 | ## 1.0.0 123 | 124 | - Initial release 125 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-resize-observer", 3 | "version": "9.1.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:ZeeCoder/use-resize-observer.git", 9 | "description": "A React hook that allows you to use a ResizeObserver to measure an element's size.", 10 | "author": "Viktor Hubert ", 11 | "license": "MIT", 12 | "keywords": [ 13 | "react", 14 | "hook", 15 | "react hook", 16 | "resize observer", 17 | "resize observer hook", 18 | "resize observer react hook", 19 | "use-resize-observer", 20 | "useresizeobserver", 21 | "resize hook", 22 | "size hook", 23 | "container query", 24 | "css in js", 25 | "measure", 26 | "size", 27 | "resize" 28 | ], 29 | "scripts": { 30 | "build": "rollup -c && tsc && rm -rf dist/utils && cp dist/index.d.ts polyfilled.d.ts", 31 | "watch": "KARMA_BROWSERS=Chrome run-p 'src:watch' 'karma:watch'", 32 | "src:watch": "rollup -c -w", 33 | "check:size": "size-limit", 34 | "check:types": "tsc -p tests", 35 | "check:lint": "eslint src/**", 36 | "test": "run-s 'build' 'check:size' 'check:types' 'check:lint' 'test:unit' 'test:create:ssr' 'test:headless:chrome'", 37 | "test:unit": "jest", 38 | "test:create:ssr": "node ./tests/ssr/create-ssr-test.js", 39 | "test:chrome": "KARMA_BROWSERS=Chrome yarn karma:run", 40 | "test:headless:chrome": "KARMA_BROWSERS=ChromeHeadless yarn karma:run", 41 | "test:firefox": "KARMA_BROWSERS=Firefox yarn karma:run", 42 | "test:headless:firefox": "KARMA_BROWSERS=FirefoxHeadless yarn karma:run", 43 | "karma:run": "karma start --singleRun", 44 | "karma:watch": "KARMA_BROWSERS=Chrome karma start", 45 | "prepublish": "yarn build", 46 | "test:bs:all": "run-s 'test:bs:modern' 'test:bs:legacy'", 47 | "test:bs:modern": "KARMA_BROWSERS=modern yarn karma:run", 48 | "test:bs:legacy": "KARMA_BROWSERS=legacy yarn karma:run", 49 | "test:bs:chrome": "KARMA_BROWSERS=bs_chrome_latest yarn karma:run", 50 | "test:bs:firefox": "KARMA_BROWSERS=bs_firefox_latest yarn karma:run", 51 | "test:bs:safari": "KARMA_BROWSERS=bs_safari_13 yarn karma:run", 52 | "test:bs:edge": "KARMA_BROWSERS=bs_edge_latest yarn karma:run", 53 | "test:bs:opera": "KARMA_BROWSERS=bs_opera_latest yarn karma:run", 54 | "test:bs:ie": "KARMA_BROWSERS=bs_ie_11 yarn karma:run", 55 | "test:bs:ios_11": "KARMA_BROWSERS=bs_ios_11 yarn karma:run", 56 | "test:bs:ios_14": "KARMA_BROWSERS=bs_ios_14 yarn karma:run", 57 | "test:bs:samsung": "KARMA_BROWSERS=bs_samsung yarn karma:run", 58 | "prepare": "husky install" 59 | }, 60 | "lint-staged": { 61 | "*.{js,ts,md}": [ 62 | "prettier --write" 63 | ] 64 | }, 65 | "files": [ 66 | "dist/*", 67 | "polyfilled*", 68 | "README.md", 69 | "LICENSE", 70 | "CHANGELOG*", 71 | "CONTRIBUTING.md", 72 | "package.json" 73 | ], 74 | "peerDependencies": { 75 | "react": "16.8.0 - 18", 76 | "react-dom": "16.8.0 - 18" 77 | }, 78 | "devDependencies": { 79 | "@babel/core": "^7.7.7", 80 | "@babel/plugin-transform-runtime": "^7.9.0", 81 | "@babel/preset-env": "^7.7.7", 82 | "@babel/preset-react": "^7.9.4", 83 | "@babel/preset-typescript": "^7.9.0", 84 | "@rollup/plugin-babel": "^5.2.1", 85 | "@rollup/plugin-inject": "^4.0.1", 86 | "@rollup/plugin-node-resolve": "^13.3.0", 87 | "@semantic-release/changelog": "^6.0.1", 88 | "@semantic-release/commit-analyzer": "^9.0.2", 89 | "@semantic-release/git": "^10.0.1", 90 | "@semantic-release/github": "^8.0.4", 91 | "@semantic-release/npm": "^9.0.1", 92 | "@semantic-release/release-notes-generator": "^10.0.3", 93 | "@size-limit/preset-small-lib": "^5.0.1", 94 | "@testing-library/react": "^13.1.1", 95 | "@types/jest": "^28.1.1", 96 | "@types/karma": "^6.3.1", 97 | "@types/karma-jasmine": "^4.0.1", 98 | "@types/react": "^18.0.8", 99 | "@types/react-dom": "^18.0.3", 100 | "@typescript-eslint/eslint-plugin": "^5.27.1", 101 | "@typescript-eslint/parser": "^5.27.1", 102 | "babel-loader": "^8.1.0", 103 | "delay": "^5.0.0", 104 | "eslint": "^8.17.0", 105 | "eslint-plugin-react-hooks": "^4.5.0", 106 | "husky": "^8.0.1", 107 | "jest": "^28.1.1", 108 | "jest-environment-jsdom": "^28.1.1", 109 | "karma": "^6.3.4", 110 | "karma-browserstack-launcher": "^1.6.0", 111 | "karma-chrome-launcher": "^3.0.0", 112 | "karma-firefox-launcher": "^2.1.1", 113 | "karma-jasmine": "^4.0.0", 114 | "karma-sourcemap-loader": "^0.3.7", 115 | "karma-spec-reporter": "^0.0.34", 116 | "karma-webpack": "^5.0.0", 117 | "lint-staged": "^13.0.1", 118 | "npm-run-all": "^4.1.5", 119 | "prettier": "^2.0.4", 120 | "react": "^18.0.0", 121 | "react-app-polyfill": "^3.0.0", 122 | "react-dom": "^18.0.0", 123 | "rollup": "^2.6.1", 124 | "semantic-release": "^19.0.3", 125 | "size-limit": "^5.0.1", 126 | "ts-jest": "^28.0.4", 127 | "typescript": "^4.3.5", 128 | "webpack": "^4.1" 129 | }, 130 | "dependencies": { 131 | "@juggle/resize-observer": "^3.3.1" 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // @see https://www.browserstack.com/automate/capabilities 2 | const customLaunchers = { 3 | modern: { 4 | bs_chrome_latest: { 5 | base: "BrowserStack", 6 | os: "Windows", 7 | os_version: "10", 8 | browser: "Chrome", 9 | browser_version: "latest", 10 | }, 11 | bs_firefox_latest: { 12 | base: "BrowserStack", 13 | os: "Windows", 14 | os_version: "10", 15 | browser: "Firefox", 16 | browser_version: "latest", 17 | }, 18 | bs_edge_latest: { 19 | base: "BrowserStack", 20 | os: "Windows", 21 | os_version: "10", 22 | browser: "Edge", 23 | browser_version: "latest", 24 | }, 25 | bs_opera_latest: { 26 | base: "BrowserStack", 27 | os: "Windows", 28 | os_version: "10", 29 | browser: "Opera", 30 | browser_version: "latest", 31 | }, 32 | // Safari 13 is very important to be listed here, as while it does have an RO implementation, 33 | // it does not support different box sizes, only content-box. 34 | bs_safari_13: { 35 | base: "BrowserStack", 36 | os: "OS X", 37 | os_version: "Catalina", 38 | browser: "Safari", 39 | browser_version: "13.0", 40 | }, 41 | bs_ios_14: { 42 | base: "BrowserStack", 43 | device: "iPhone 11", 44 | os: "ios", 45 | os_version: "14", 46 | }, 47 | bs_samsung: { 48 | base: "BrowserStack", 49 | device: "Samsung Galaxy Note 10", 50 | os: "Android", 51 | os_version: "9.0", 52 | }, 53 | }, 54 | legacy: { 55 | bs_ios_11: { 56 | base: "BrowserStack", 57 | device: "iPhone X", 58 | os: "ios", 59 | os_version: "11", 60 | }, 61 | bs_ie_11: { 62 | base: "BrowserStack", 63 | os: "Windows", 64 | os_version: "10", 65 | browser: "IE", 66 | browser_version: "11.0", 67 | }, 68 | }, 69 | }; 70 | 71 | module.exports = function (karmaConfig) { 72 | const { BS_USERNAME, BS_ACCESS_KEY, KARMA_BROWSERS } = process.env; 73 | 74 | const browsers = 75 | KARMA_BROWSERS === "modern" 76 | ? Object.keys(customLaunchers.modern) 77 | : KARMA_BROWSERS === "legacy" 78 | ? Object.keys(customLaunchers.legacy) 79 | : KARMA_BROWSERS 80 | ? KARMA_BROWSERS.split(",").map((val) => val.trim()) 81 | : ["ChromeHeadless"]; 82 | 83 | const useBrowserStack = browsers[0].startsWith("bs_"); 84 | 85 | const polyfilledRun = browsers.reduce( 86 | (carry, browser) => 87 | carry || Object.keys(customLaunchers.legacy).includes(browser), 88 | false 89 | ); 90 | 91 | let testFilePattern = "tests/*.tsx"; 92 | // let testFilePattern = "tests/ssr.test.tsx"; 93 | // let testFilePattern = "tests/basic.tsx"; 94 | // let testFilePattern = "tests/testing-lib.tsx"; 95 | 96 | let transpileExcludePattern = /node_modules/; 97 | let presetEndModules = false; 98 | let transformRuntimeUseESModules = true; 99 | if (polyfilledRun) { 100 | // IE runs a special set of (polyfilled) tests 101 | testFilePattern = "tests/ie/*.tsx"; 102 | // Processing everything (including node_modules) for IE11 to make sure 3rd 103 | // party deps can run during the tests. 104 | transpileExcludePattern = /^$/; 105 | presetEndModules = "commonjs"; 106 | transformRuntimeUseESModules = false; 107 | } 108 | 109 | const config = { 110 | basePath: ".", 111 | frameworks: ["jasmine", "webpack"], 112 | files: [ 113 | { 114 | pattern: testFilePattern, 115 | watched: !useBrowserStack, 116 | }, 117 | ], 118 | reporters: ["spec"], 119 | preprocessors: { 120 | [testFilePattern]: ["webpack", "sourcemap"], 121 | }, 122 | ...(useBrowserStack && { 123 | browserStack: { 124 | username: BS_USERNAME, 125 | accessKey: BS_ACCESS_KEY, 126 | project: "use-resize-observer", 127 | }, 128 | }), 129 | // @see https://karma-runner.github.io/5.2/config/files.html 130 | autoWatch: !useBrowserStack, 131 | browsers, 132 | customLaunchers: { 133 | ...customLaunchers.modern, 134 | ...customLaunchers.legacy, 135 | }, 136 | webpack: { 137 | mode: "development", 138 | devtool: "inline-source-map", 139 | module: { 140 | rules: [ 141 | { 142 | test: /\.(ts|tsx|js|jsx)$/, 143 | exclude: transpileExcludePattern, 144 | use: { 145 | loader: "babel-loader", 146 | options: { 147 | presets: [ 148 | [ 149 | "@babel/preset-env", 150 | { loose: true, modules: presetEndModules }, 151 | ], 152 | "@babel/preset-react", 153 | "@babel/preset-typescript", 154 | ], 155 | plugins: [ 156 | [ 157 | "@babel/transform-runtime", 158 | { useESModules: transformRuntimeUseESModules }, 159 | ], 160 | ], 161 | }, 162 | }, 163 | }, 164 | ], 165 | }, 166 | resolve: { 167 | extensions: [".ts", ".tsx", ".js", ".jsx"], 168 | }, 169 | }, 170 | }; 171 | 172 | karmaConfig.set(config); 173 | }; 174 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | useRef, 5 | useMemo, 6 | RefObject, 7 | RefCallback, 8 | useCallback, 9 | } from "react"; 10 | import useResolvedElement from "./utils/useResolvedElement"; 11 | import extractSize from "./utils/extractSize"; 12 | 13 | export type ObservedSize = { 14 | width: number | undefined; 15 | height: number | undefined; 16 | }; 17 | 18 | export type ResizeHandler = (size: ObservedSize) => void; 19 | 20 | type HookResponse = { 21 | ref: RefCallback; 22 | } & ObservedSize; 23 | 24 | // Declaring my own type here instead of using the one provided by TS (available since 4.2.2), because this way I'm not 25 | // forcing consumers to use a specific TS version. 26 | export type ResizeObserverBoxOptions = 27 | | "border-box" 28 | | "content-box" 29 | | "device-pixel-content-box"; 30 | 31 | declare global { 32 | interface ResizeObserverEntry { 33 | readonly devicePixelContentBoxSize: ReadonlyArray; 34 | } 35 | } 36 | 37 | export type RoundingFunction = (n: number) => number; 38 | 39 | function useResizeObserver( 40 | opts: { 41 | ref?: RefObject | T | null | undefined; 42 | onResize?: ResizeHandler; 43 | box?: ResizeObserverBoxOptions; 44 | round?: RoundingFunction; 45 | } = {} 46 | ): HookResponse { 47 | // Saving the callback as a ref. With this, I don't need to put onResize in the 48 | // effect dep array, and just passing in an anonymous function without memoising 49 | // will not reinstantiate the hook's ResizeObserver. 50 | const onResize = opts.onResize; 51 | const onResizeRef = useRef(undefined); 52 | onResizeRef.current = onResize; 53 | const round = opts.round || Math.round; 54 | 55 | // Using a single instance throughout the hook's lifetime 56 | const resizeObserverRef = useRef<{ 57 | box?: ResizeObserverBoxOptions; 58 | round?: RoundingFunction; 59 | instance: ResizeObserver; 60 | }>(); 61 | 62 | const [size, setSize] = useState<{ 63 | width?: number; 64 | height?: number; 65 | }>({ 66 | width: undefined, 67 | height: undefined, 68 | }); 69 | 70 | // In certain edge cases the RO might want to report a size change just after 71 | // the component unmounted. 72 | const didUnmount = useRef(false); 73 | useEffect(() => { 74 | didUnmount.current = false; 75 | 76 | return () => { 77 | didUnmount.current = true; 78 | }; 79 | }, []); 80 | 81 | // Using a ref to track the previous width / height to avoid unnecessary renders. 82 | const previous: { 83 | current: { 84 | width?: number; 85 | height?: number; 86 | }; 87 | } = useRef({ 88 | width: undefined, 89 | height: undefined, 90 | }); 91 | 92 | // This block is kinda like a useEffect, only it's called whenever a new 93 | // element could be resolved based on the ref option. It also has a cleanup 94 | // function. 95 | const refCallback = useResolvedElement( 96 | useCallback( 97 | (element) => { 98 | // We only use a single Resize Observer instance, and we're instantiating it on demand, only once there's something to observe. 99 | // This instance is also recreated when the `box` option changes, so that a new observation is fired if there was a previously observed element with a different box option. 100 | if ( 101 | !resizeObserverRef.current || 102 | resizeObserverRef.current.box !== opts.box || 103 | resizeObserverRef.current.round !== round 104 | ) { 105 | resizeObserverRef.current = { 106 | box: opts.box, 107 | round, 108 | instance: new ResizeObserver((entries) => { 109 | const entry = entries[0]; 110 | 111 | const boxProp = 112 | opts.box === "border-box" 113 | ? "borderBoxSize" 114 | : opts.box === "device-pixel-content-box" 115 | ? "devicePixelContentBoxSize" 116 | : "contentBoxSize"; 117 | 118 | const reportedWidth = extractSize(entry, boxProp, "inlineSize"); 119 | const reportedHeight = extractSize(entry, boxProp, "blockSize"); 120 | 121 | const newWidth = reportedWidth ? round(reportedWidth) : undefined; 122 | const newHeight = reportedHeight 123 | ? round(reportedHeight) 124 | : undefined; 125 | 126 | if ( 127 | previous.current.width !== newWidth || 128 | previous.current.height !== newHeight 129 | ) { 130 | const newSize = { width: newWidth, height: newHeight }; 131 | previous.current.width = newWidth; 132 | previous.current.height = newHeight; 133 | if (onResizeRef.current) { 134 | onResizeRef.current(newSize); 135 | } else { 136 | if (!didUnmount.current) { 137 | setSize(newSize); 138 | } 139 | } 140 | } 141 | }), 142 | }; 143 | } 144 | 145 | resizeObserverRef.current.instance.observe(element, { box: opts.box }); 146 | 147 | return () => { 148 | if (resizeObserverRef.current) { 149 | resizeObserverRef.current.instance.unobserve(element); 150 | } 151 | }; 152 | }, 153 | [opts.box, round] 154 | ), 155 | opts.ref 156 | ); 157 | 158 | return useMemo( 159 | () => ({ 160 | ref: refCallback, 161 | width: size.width, 162 | height: size.height, 163 | }), 164 | [refCallback, size.width, size.height] 165 | ); 166 | } 167 | 168 | export default useResizeObserver; 169 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [9.1.0](https://github.com/ZeeCoder/use-resize-observer/compare/v9.0.2...v9.1.0) (2022-11-22) 2 | 3 | ### Bug Fixes 4 | 5 | - trigger release ([da154e1](https://github.com/ZeeCoder/use-resize-observer/commit/da154e1db4e1c2b21fef722d998fd2adaba46d9f)) 6 | 7 | ### Features 8 | 9 | - Exported some TS types ([3895bdf](https://github.com/ZeeCoder/use-resize-observer/commit/3895bdf98687f285e707697d451231ab7f46d189)), closes [#98](https://github.com/ZeeCoder/use-resize-observer/issues/98) 10 | 11 | # [9.1.0-alpha.1](https://github.com/ZeeCoder/use-resize-observer/compare/v9.0.2...v9.1.0-alpha.1) (2022-11-21) 12 | 13 | ### Features 14 | 15 | - Exported some TS types ([e056e19](https://github.com/ZeeCoder/use-resize-observer/commit/e056e191499c713dc7bdd872530cfb9786e1af3e)), closes [#98](https://github.com/ZeeCoder/use-resize-observer/issues/98) 16 | 17 | ## [9.0.2](https://github.com/ZeeCoder/use-resize-observer/compare/v9.0.1...v9.0.2) (2022-06-13) 18 | 19 | ### Bug Fixes 20 | 21 | - trigger release ([01fccc6](https://github.com/ZeeCoder/use-resize-observer/commit/01fccc6bf5036903a650b42ded58fda4e2907149)) 22 | 23 | ## [9.0.1](https://github.com/ZeeCoder/use-resize-observer/compare/v9.0.0...v9.0.1) (2022-06-13) 24 | 25 | ### Bug Fixes 26 | 27 | - Made element resolution compatbile with concurrent mode ([c9c6689](https://github.com/ZeeCoder/use-resize-observer/commit/c9c66894abeb8fefd5916a0c119be809357fcdf5)) 28 | - Small changes and chores ([0cb6800](https://github.com/ZeeCoder/use-resize-observer/commit/0cb68008619976c45dfa133a332c75766a43d7d4)) 29 | 30 | # [9.0.0](https://github.com/ZeeCoder/use-resize-observer/compare/v8.0.0...v9.0.0) (2022-05-15) 31 | 32 | ### Bug Fixes 33 | 34 | - Added some fixes for React 18. ([852d976](https://github.com/ZeeCoder/use-resize-observer/commit/852d976e481a215671be95964c6fa05825eee82a)), closes [#90](https://github.com/ZeeCoder/use-resize-observer/issues/90) [#91](https://github.com/ZeeCoder/use-resize-observer/issues/91) [#92](https://github.com/ZeeCoder/use-resize-observer/issues/92) 35 | 36 | ### BREAKING CHANGES 37 | 38 | - The lib now takes "Element", not only "HTMLElement", to 39 | be consistent with ResizeObserver. 40 | 41 | # [8.0.0](https://github.com/ZeeCoder/use-resize-observer/compare/v7.0.1...v8.0.0) (2021-08-28) 42 | 43 | ### Bug Fixes 44 | 45 | - The `onResize` callback is no longer incorrectly called with the same values. ([bd0f3c8](https://github.com/ZeeCoder/use-resize-observer/commit/bd0f3c8597bac0d853b88cf585256aac1bd4f554)) 46 | 47 | ### Features 48 | 49 | - Added the `box` option ([0ca6c23](https://github.com/ZeeCoder/use-resize-observer/commit/0ca6c23dd5573526f1dd716851083f922ca73f68)), closes [#31](https://github.com/ZeeCoder/use-resize-observer/issues/31) [#57](https://github.com/ZeeCoder/use-resize-observer/issues/57) 50 | - Added the `round` option. ([aa38199](https://github.com/ZeeCoder/use-resize-observer/commit/aa38199f21f60bd4a361a2198e9e5f200bf5287c)), closes [#55](https://github.com/ZeeCoder/use-resize-observer/issues/55) [#46](https://github.com/ZeeCoder/use-resize-observer/issues/46) [#61](https://github.com/ZeeCoder/use-resize-observer/issues/61) 51 | 52 | ### BREAKING CHANGES 53 | 54 | - Removed `resize-observer-polyfill` in favour of `@juggle/resize-observer`. ([8afc8f6](https://github.com/ZeeCoder/use-resize-observer/commit/8afc8f6c52ee047a41ac107379ebdf27e1a95997)) 55 | 56 | # [7.1.0](https://github.com/ZeeCoder/use-resize-observer/compare/v7.0.1...v7.1.0) (2021-08-28) 57 | 58 | **This was an accidental release**, and an equivalent of V8. 59 | 60 | ## [7.0.1](https://github.com/ZeeCoder/use-resize-observer/compare/v7.0.0...v7.0.1) (2021-07-27) 61 | 62 | ### Bug Fixes 63 | 64 | - Removed unnecessary entries.length check ([3211d33](https://github.com/ZeeCoder/use-resize-observer/commit/3211d338117b0d2a97ccb229683eb8458de81d01)) 65 | - Undefined HTMLElement is no longer an issue in certain SSR edge cases. ([599cace](https://github.com/ZeeCoder/use-resize-observer/commit/599cace5c33ecd4276a0fe2848e0ed920f81e2fe)), closes [#74](https://github.com/ZeeCoder/use-resize-observer/issues/74) [#62](https://github.com/ZeeCoder/use-resize-observer/issues/62) 66 | 67 | # [7.0.0](https://github.com/ZeeCoder/use-resize-observer/compare/v6.1.0...v7.0.0) (2020-11-11) 68 | 69 | ### Bug Fixes 70 | 71 | - Only instantiating a ResizeObserver instance if there's actually something to 72 | observe. This for example means that if you pass in `null` or undefined as the 73 | ref, or if neither the default ref or RefCallback returned from the hook are 74 | in use, then no ResizeObserver instance will get created until there's an 75 | actual element to observe. Resolves: #42 76 | - Fixed an error where in certain edge cases the hook tried to set state when 77 | its host component already unmounted. 78 | 79 | ### Features 80 | 81 | - The `ref` option now accepts raw elements as well. 82 | 83 | ### BREAKING CHANGES 84 | 85 | - The returned ref is now a RefCallback, not a ref object. Resolves: #43, #45 86 | - The returned ref will always be the same RefCallback. 87 | Previously when a custom ref object was passed, it was returned as well from 88 | the hook as "ref". 89 | - Compared to 6.2.0-alpha.1 There's no `callbackRef` return value anymore. 90 | 91 | ### Misc 92 | 93 | - Using package.json file attr instead of gitignore ([c58f34e](https://github.com/ZeeCoder/use-resize-observer/commit/c58f34e11b68ef9622a6b2528da8ee68a9685211)) 94 | - Added Semantic Release ([55f6368](https://github.com/ZeeCoder/use-resize-observer/commit/55f6368c1b0c3154bfd6ed16e089763de0b0ba47)) 95 | - Handling custom refs (through options), the default ref and the RefCallback 96 | has been greatly refactored internally (into the `useResolvedElement` 97 | hook), to handle more edge cases with the way refs are handled. 98 | - Tests based on react testing library were refactored to make them much simpler 99 | and more approachable. 100 | - Added [contributing guidelines](./CONTRIBUTING.md) 101 | - Added tests in real browsers with BrowserStack, so that we ensure the lib 102 | works all the way back to IE11. 103 | - Switched to GitHub Actions from Travis, as builds started to freeze. (They've 104 | also announced a [limit on OS projects](https://blog.travis-ci.com/2020-11-02-travis-ci-new-billing).) 105 | 106 | # [7.0.0-alpha.4](https://github.com/ZeeCoder/use-resize-observer/compare/v7.0.0-alpha.3...v7.0.0-alpha.4) (2020-11-11) 107 | 108 | ### Bug Fixes 109 | 110 | - Using package.json file attr instead of gitignore ([74ea0a9](https://github.com/ZeeCoder/use-resize-observer/commit/74ea0a97c3575388506536a700586aecf0ba0816)) 111 | 112 | # [7.0.0-alpha.3](https://github.com/ZeeCoder/use-resize-observer/compare/v7.0.0-alpha.2...v7.0.0-alpha.3) (2020-11-11) 113 | 114 | ### Bug Fixes 115 | 116 | - Added Semantic Release ([5074c0f](https://github.com/ZeeCoder/use-resize-observer/commit/5074c0fefd29e53a8ed9a4672ba043ad3be6d972), [54a83ce](https://github.com/ZeeCoder/use-resize-observer/commit/54a83cede6fcb8dbfa9e0f9a0ea2f1f4557b606f)) 117 | 118 | # [7.0.0-alpha.2](https://github.com/ZeeCoder/use-resize-observer/compare/v7.0.0-alpha.1...v7.0.0-alpha.2) (2020-11-11) 119 | 120 | Skipped Release 121 | -------------------------------------------------------------------------------- /tests/basic.tsx: -------------------------------------------------------------------------------- 1 | // Tests written with react testing library 2 | import React, { useRef, useState } from "react"; 3 | import { render, cleanup, act } from "@testing-library/react"; 4 | import createController from "./utils/createController"; 5 | import useResizeObserver, { ObservedSize, ResizeHandler } from "../"; 6 | import useMergedCallbackRef from "./utils/useMergedCallbackRef"; 7 | import awaitNextFrame from "./utils/awaitNextFrame"; 8 | import useRenderTrigger from "./utils/useRenderTrigger"; 9 | 10 | afterEach(() => { 11 | cleanup(); 12 | }); 13 | 14 | describe("Basic tests", () => { 15 | it("should render with undefined sizes at first", async () => { 16 | const controller = createController(); 17 | const Test = () => { 18 | const { ref, width, height } = useResizeObserver(); 19 | controller.reportMeasuredSize({ width, height }); 20 | 21 | return
; 22 | }; 23 | 24 | render(); 25 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 26 | }); 27 | 28 | it("should render with custom defaults", async () => { 29 | const controller = createController(); 30 | const Test = () => { 31 | const { ref, width = 24, height = 42 } = useResizeObserver(); 32 | controller.reportMeasuredSize({ width, height }); 33 | 34 | return
; 35 | }; 36 | 37 | // By running this assertion immediately, it should check the default values 38 | // instead ot the first on-mount measurement. 39 | render(); 40 | controller.assertMeasuredSize({ width: 24, height: 42 }); 41 | }); 42 | 43 | it("should follow size changes correctly with appropriate render count and without sub-pixels as they're used in CSS", async () => { 44 | const controller = createController(); 45 | const Test = () => { 46 | const { ref, width = 24, height = 42 } = useResizeObserver(); 47 | controller.reportMeasuredSize({ width, height }); 48 | controller.incrementRenderCount(); 49 | 50 | const mergedCallbackRef = useMergedCallbackRef( 51 | ref, 52 | (element: HTMLDivElement) => { 53 | controller.provideSetSizeFunction(element); 54 | } 55 | ); 56 | 57 | return
; 58 | }; 59 | 60 | // Default render + first measurement 61 | render(); 62 | await act(async () => { 63 | await awaitNextFrame(); 64 | }); 65 | controller.assertRenderCount(2); 66 | 67 | await act(async () => { 68 | await controller.setSize({ width: 100, height: 200 }); 69 | }); 70 | controller.assertMeasuredSize({ width: 100, height: 200 }); 71 | controller.assertRenderCount(3); 72 | 73 | await act(async () => { 74 | await controller.setSize({ width: 321, height: 456 }); 75 | }); 76 | controller.assertMeasuredSize({ width: 321, height: 456 }); 77 | controller.assertRenderCount(4); 78 | }); 79 | 80 | it("should handle multiple instances", async () => { 81 | const Test = ({ 82 | controller, 83 | }: { 84 | controller: ReturnType; 85 | }) => { 86 | const { ref, width = 24, height = 42 } = useResizeObserver(); 87 | controller.reportMeasuredSize({ width, height }); 88 | controller.incrementRenderCount(); 89 | 90 | const mergedCallbackRef = useMergedCallbackRef( 91 | ref, 92 | (element: HTMLDivElement) => { 93 | controller.provideSetSizeFunction(element); 94 | } 95 | ); 96 | 97 | return
; 98 | }; 99 | const controller1 = createController(); 100 | const controller2 = createController(); 101 | 102 | render( 103 | <> 104 | 105 | 106 | 107 | ); 108 | 109 | await act(async () => { 110 | await controller1.setSize({ width: 100, height: 200 }); 111 | await controller2.setSize({ width: 300, height: 400 }); 112 | }); 113 | 114 | controller1.assertMeasuredSize({ width: 100, height: 200 }); 115 | controller2.assertMeasuredSize({ width: 300, height: 400 }); 116 | 117 | controller1.assertRenderCount(2); 118 | controller2.assertRenderCount(2); 119 | 120 | await act(async () => { 121 | await controller2.setSize({ width: 321, height: 456 }); 122 | }); 123 | controller1.assertMeasuredSize({ width: 100, height: 200 }); 124 | controller2.assertMeasuredSize({ width: 321, height: 456 }); 125 | controller1.assertRenderCount(2); 126 | controller2.assertRenderCount(3); 127 | }); 128 | 129 | it("should handle ref objects on mount", async () => { 130 | const controller = createController(); 131 | const Test = () => { 132 | const ref = useRef(null); 133 | const { width, height } = useResizeObserver({ ref }); 134 | controller.reportMeasuredSize({ width, height }); 135 | 136 | return
; 137 | }; 138 | 139 | render(); 140 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 141 | 142 | // Actual measurement 143 | await act(async () => { 144 | await awaitNextFrame(); 145 | }); 146 | controller.assertMeasuredSize({ width: 100, height: 200 }); 147 | }); 148 | 149 | it("should handle ref objects calling onResize on mount", async () => { 150 | const controller = createController(); 151 | const Test = () => { 152 | const ref = useRef(null); 153 | useResizeObserver({ 154 | ref, 155 | onResize: (size) => { 156 | controller.reportMeasuredSize(size); 157 | }, 158 | }); 159 | 160 | return
; 161 | }; 162 | 163 | render(); 164 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 165 | 166 | // Actual measurement 167 | await act(async () => { 168 | await awaitNextFrame(); 169 | }); 170 | controller.assertMeasuredSize({ width: 100, height: 200 }); 171 | }); 172 | 173 | it("should be able to reuse the same ref to measure different elements", async () => { 174 | let switchRefs = (): void => { 175 | throw new Error(`"switchRefs" should've been implemented by now.`); 176 | }; 177 | const controller = createController(); 178 | const Test = () => { 179 | const ref1 = useRef(null); 180 | const ref2 = useRef(null); 181 | const [stateRef, setStateRef] = useState(ref1); // Measuring ref1 first 182 | switchRefs = () => setStateRef(ref2); 183 | const response = useResizeObserver({ ref: stateRef }); 184 | controller.reportMeasuredSize(response); 185 | 186 | return ( 187 | <> 188 |
189 |
190 | 191 | ); 192 | }; 193 | 194 | render(); 195 | 196 | // Default 197 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 198 | 199 | // Div 1 measurement 200 | await act(async () => { 201 | await awaitNextFrame(); 202 | }); 203 | controller.assertMeasuredSize({ width: 100, height: 200 }); 204 | 205 | // Div 2 measurement 206 | await act(async () => { 207 | switchRefs(); 208 | }); 209 | await act(async () => { 210 | await awaitNextFrame(); 211 | }); 212 | controller.assertMeasuredSize({ width: 150, height: 250 }); 213 | }); 214 | 215 | it("should not trigger unnecessary renders with the same width or height", async () => { 216 | const controller = createController(); 217 | const Test = () => { 218 | const { ref, width, height } = useResizeObserver(); 219 | controller.reportMeasuredSize({ width, height }); 220 | controller.incrementRenderCount(); 221 | 222 | const mergedCallbackRef = useMergedCallbackRef( 223 | ref, 224 | (element: HTMLDivElement) => { 225 | controller.provideSetSizeFunction(element); 226 | } 227 | ); 228 | 229 | return
; 230 | }; 231 | 232 | render(); 233 | 234 | // Default render + first measurement 235 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 236 | await act(async () => { 237 | await awaitNextFrame(); 238 | }); 239 | controller.assertRenderCount(2); 240 | 241 | await act(async () => { 242 | await controller.setSize({ width: 100, height: 102 }); 243 | }); 244 | controller.assertMeasuredSize({ width: 100, height: 102 }); 245 | controller.assertRenderCount(3); 246 | 247 | // Shouldn't trigger on subpixel values that are rounded to be the same as the 248 | // previous size 249 | await act(async () => { 250 | await controller.setSize({ width: 100.4, height: 102.4 }); 251 | }); 252 | controller.assertMeasuredSize({ width: 100, height: 102 }); 253 | controller.assertRenderCount(3); 254 | }); 255 | 256 | it("should keep the same response instance between renders if nothing changed", async () => { 257 | const responses: ReturnType[] = []; 258 | const controller = createController(); 259 | const Test = () => { 260 | const response = useResizeObserver(); 261 | if (response.width) { 262 | responses.push(response); 263 | } 264 | controller.triggerRender = useRenderTrigger(); 265 | 266 | return
; 267 | }; 268 | 269 | render(); 270 | await act(async () => { 271 | await awaitNextFrame(); 272 | }); 273 | 274 | await act(async () => { 275 | await controller.triggerRender(); 276 | }); 277 | // ignoring the first "undefined" measurement before uRO received the element 278 | responses.unshift(); 279 | 280 | // As the size did not change between renders, the response objects should be the same by reference. 281 | expect(responses.length).toBe(2); 282 | expect(responses[0]).toBe(responses[1]); 283 | }); 284 | 285 | it("should ignore invalid custom refs", async () => { 286 | const controller = createController(); 287 | const Test = () => { 288 | const response = useResizeObserver({ ref: {} as HTMLDivElement }); 289 | controller.reportMeasuredSize(response); 290 | 291 | return
; 292 | }; 293 | 294 | render(); 295 | 296 | // Since no refs were passed in with an element to be measured, the hook should 297 | // stay on the defaults 298 | await awaitNextFrame(); 299 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 300 | }); 301 | 302 | it("should be able to work with onResize instead of rendering the values", async () => { 303 | const observations: ObservedSize[] = []; 304 | const controller = createController(); 305 | const Test = () => { 306 | const { ref, width, height } = useResizeObserver({ 307 | onResize: (size) => observations.push(size), 308 | }); 309 | controller.reportMeasuredSize({ width, height }); 310 | controller.incrementRenderCount(); 311 | 312 | const mergedCallbackRef = useMergedCallbackRef( 313 | ref, 314 | (element: HTMLDivElement) => { 315 | controller.provideSetSizeFunction(element); 316 | } 317 | ); 318 | 319 | return
; 320 | }; 321 | 322 | render(); 323 | 324 | await act(async () => { 325 | await controller.setSize({ width: 100, height: 200 }); 326 | await controller.setSize({ width: 101, height: 201 }); 327 | }); 328 | 329 | // Should stay at default as width/height is not passed to the hook response 330 | // when an onResize callback is given 331 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 332 | 333 | expect(observations.length).toBe(2); 334 | expect(observations[0]).toEqual({ width: 100, height: 200 }); 335 | expect(observations[1]).toEqual({ width: 101, height: 201 }); 336 | 337 | // Should render once on mount only 338 | controller.assertRenderCount(1); 339 | }); 340 | 341 | it("should handle if the onResize handler changes, with the correct render counts", async () => { 342 | const controller = createController(); 343 | let changeOnResizeHandler = (handler: ResizeHandler) => {}; 344 | const Test = () => { 345 | const [onResize, setOnResize] = useState(() => () => {}); 346 | changeOnResizeHandler = (handler) => setOnResize(() => handler); 347 | const { ref, width, height } = useResizeObserver({ onResize }); 348 | controller.reportMeasuredSize({ width, height }); 349 | controller.incrementRenderCount(); 350 | 351 | const mergedCallbackRef = useMergedCallbackRef( 352 | ref, 353 | (element: HTMLDivElement) => { 354 | controller.provideSetSizeFunction(element); 355 | } 356 | ); 357 | 358 | return
; 359 | }; 360 | 361 | render(); 362 | 363 | // Since `onResize` is used, no extra renders should've been triggered at this 364 | // point. (As opposed to the defaults where the hook would trigger a render 365 | // with the first measurement.) 366 | controller.assertRenderCount(1); 367 | 368 | const observations1: ObservedSize[] = []; 369 | const observations2: ObservedSize[] = []; 370 | // Establishing a default onResize handler, which'll be measured when the resize handler is set. 371 | await act(async () => { 372 | await controller.setSize({ width: 1, height: 1 }); 373 | }); 374 | controller.assertRenderCount(1); 375 | 376 | await act(async () => { 377 | changeOnResizeHandler((size) => observations1.push(size)); 378 | }); 379 | await act(async () => { 380 | await controller.setSize({ width: 1, height: 2 }); 381 | await controller.setSize({ width: 3, height: 4 }); 382 | }); 383 | controller.assertRenderCount(2); 384 | 385 | await act(async () => { 386 | changeOnResizeHandler((size) => observations2.push(size)); 387 | }); 388 | await act(async () => { 389 | await controller.setSize({ width: 5, height: 6 }); 390 | await controller.setSize({ width: 7, height: 8 }); 391 | }); 392 | controller.assertRenderCount(3); 393 | 394 | expect(observations1.length).toBe(2); 395 | expect(observations1[0]).toEqual({ width: 1, height: 2 }); 396 | expect(observations1[1]).toEqual({ width: 3, height: 4 }); 397 | 398 | expect(observations2.length).toBe(2); 399 | expect(observations2[0]).toEqual({ width: 5, height: 6 }); 400 | expect(observations2[1]).toEqual({ width: 7, height: 8 }); 401 | }); 402 | }); 403 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # use-resize-observer 2 | 3 | 4 |

5 |
6 | useResizeObserver 7 |
8 |
9 |

10 | 11 | A React hook that allows you to use a ResizeObserver to measure an element's size. 12 | 13 | [![npm version](https://badge.fury.io/js/use-resize-observer.svg)](https://npmjs.com/package/use-resize-observer) 14 | [![build](https://github.com/ZeeCoder/use-resize-observer/workflows/Testing/badge.svg)](https://github.com/ZeeCoder/use-resize-observer/actions/workflows/testing.yml) 15 | [![BrowserStack Status](https://automate.browserstack.com/badge.svg?badge_key=aVpjV2RZbThnWnh2S0FvREh0cGRtRHRCNzYwUmw4N0Z4WUxybHM0WkpqST0tLW9RT0tDeGk3OVU2WkNtalpON29xWFE9PQ==--ec6a97c52cd7ad30417612ca3f5df511eef5d631)](https://automate.browserstack.com/public-build/aVpjV2RZbThnWnh2S0FvREh0cGRtRHRCNzYwUmw4N0Z4WUxybHM0WkpqST0tLW9RT0tDeGk3OVU2WkNtalpON29xWFE9PQ==--ec6a97c52cd7ad30417612ca3f5df511eef5d631) 16 | 17 | ## Highlights 18 | 19 | - Written in **TypeScript**. 20 | - **Tiny**: [648B](.size-limit.json) (minified, gzipped) Monitored by [size-limit](https://github.com/ai/size-limit). 21 | - Exposes an **onResize callback** if you need more control. 22 | - `box` [option](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe#syntax). 23 | - Works with **SSR**. 24 | - Works with **CSS-in-JS**. 25 | - **Supports custom refs** in case you [had one already](#passing-in-your-own-ref). 26 | - **Uses RefCallback by default** To address delayed mounts and changing ref elements. 27 | - **Ships a polyfilled version** 28 | - Handles many edge cases you might not even think of. 29 | (See this documentation and the test cases.) 30 | - Easy to compose ([Throttle / Debounce](#throttle--debounce), [Breakpoints](#breakpoints)) 31 | - **Tested in real browsers** (Currently latest Chrome, Firefox, Edge, Safari, Opera, IE 11, iOS and Android, sponsored by BrowserStack) 32 | 33 | ## In Action 34 | 35 | [CodeSandbox Demo](https://codesandbox.io/s/nrp0w2r5z0) 36 | 37 | ## Install 38 | 39 | ```sh 40 | yarn add use-resize-observer --dev 41 | # or 42 | npm install use-resize-observer --save-dev 43 | ``` 44 | 45 | ## Options 46 | 47 | | Option | Type | Description | Default | 48 | | -------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | -------------- | 49 | | ref | undefined | RefObject | HTMLElement | A ref or element to observe. | undefined | 50 | | box | undefined | "border-box" | "content-box" | "device-pixel-content-box" | The [box model](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe#syntax) to use for observation. | "content-box" | 51 | | onResize | undefined | ({ width?: number, height?: number }) => void | A callback receiving the element size. If given, then the hook will not return the size, and instead will call this callback. | undefined | 52 | | round | undefined | (n: number) => number | A function to use for rounding values instead of the default. | `Math.round()` | 53 | 54 | ## Response 55 | 56 | | Name | Type | Description | 57 | | ------ | ----------------------- | ---------------------------------------------- | 58 | | ref | RefCallback | A callback to be passed to React's "ref" prop. | 59 | | width | undefined | number | The width (or "inlineSize") of the element. | 60 | | height | undefined | number | The height (or "blockSize") of the element. | 61 | 62 | ## Basic Usage 63 | 64 | Note that the default builds are not polyfilled! For instructions and alternatives, 65 | see the [Transpilation / Polyfilling](#transpilation--polyfilling) section. 66 | 67 | ```tsx 68 | import React from "react"; 69 | import useResizeObserver from "use-resize-observer"; 70 | 71 | const App = () => { 72 | const { ref, width = 1, height = 1 } = useResizeObserver(); 73 | 74 | return ( 75 |
76 | Size: {width}x{height} 77 |
78 | ); 79 | }; 80 | ``` 81 | 82 | To observe a different box size other than content box, pass in the `box` option, like so: 83 | 84 | ```tsx 85 | const { ref, width, height } = useResizeObserver({ 86 | box: "border-box", 87 | }); 88 | ``` 89 | 90 | Note that if the browser does not support the given box type, then the hook won't report any sizes either. 91 | 92 | ### Box Options 93 | 94 | Note that box options are experimental, and as such are not supported by all browsers that implemented ResizeObservers. (See [here](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry).) 95 | 96 | `content-box` (default) 97 | 98 | Safe to use by all browsers that implemented ResizeObservers. The hook internally will fall back to `contentRect` from 99 | the old spec in case `contentBoxSize` is not available. 100 | 101 | `border-box` 102 | 103 | Supported well for the most part by evergreen browsers. If you need to support older versions of these browsers however, 104 | then you may want to feature-detect for support, and optionally include a polyfill instead of the native implementation. 105 | 106 | `device-pixel-content-box` 107 | 108 | Surma has a [very good article](https://web.dev/device-pixel-content-box/) on how this allows us to do pixel perfect 109 | rendering. At the time of writing, however this has very limited support. 110 | The advices on feature detection for `border-box` apply here too. 111 | 112 | ### Custom Rounding 113 | 114 | By default this hook passes the measured values through `Math.round()`, to avoid re-rendering on every subpixel changes. 115 | 116 | If this is not what you want, then you can provide your own function: 117 | 118 | **Rounding Down Reported Values** 119 | 120 | ```tsx 121 | const { ref, width, height } = useResizeObserver({ 122 | round: Math.floor, 123 | }); 124 | ``` 125 | 126 | **Skipping Rounding** 127 | 128 | ```tsx 129 | import React from "react"; 130 | import useResizeObserver from "use-resize-observer"; 131 | 132 | // Outside the hook to ensure this instance does not change unnecessarily. 133 | const noop = (n) => n; 134 | 135 | const App = () => { 136 | const { 137 | ref, 138 | width = 1, 139 | height = 1, 140 | } = useResizeObserver({ round: noop }); 141 | 142 | return ( 143 |
144 | Size: {width}x{height} 145 |
146 | ); 147 | }; 148 | ``` 149 | 150 | Note that the round option is sensitive to the function reference, so make sure you either use `useCallback` 151 | or declare your rounding function outside of the hook's function scope, if it does not rely on any hook state. 152 | (As shown above.) 153 | 154 | ### Getting the Raw Element from the Default `RefCallback` 155 | 156 | Note that "ref" in the above examples is a `RefCallback`, not a `RefObject`, meaning you won't be 157 | able to access "ref.current" if you need the element itself. 158 | 159 | To get the raw element, either you use your own RefObject (see later in this doc), 160 | or you can merge the returned ref with one of your own: 161 | 162 | ```tsx 163 | import React, { useCallback, useEffect, useRef } from "react"; 164 | import useResizeObserver from "use-resize-observer"; 165 | import mergeRefs from "react-merge-refs"; 166 | 167 | const App = () => { 168 | const { ref, width = 1, height = 1 } = useResizeObserver(); 169 | 170 | const mergedCallbackRef = mergeRefs([ 171 | ref, 172 | (element: HTMLDivElement) => { 173 | // Do whatever you want with the `element`. 174 | }, 175 | ]); 176 | 177 | return ( 178 |
179 | Size: {width}x{height} 180 |
181 | ); 182 | }; 183 | ``` 184 | 185 | ## Passing in Your Own `ref` 186 | 187 | You can pass in your own ref instead of using the one provided. 188 | This can be useful if you already have a ref you want to measure. 189 | 190 | ```ts 191 | const ref = useRef(null); 192 | const { width, height } = useResizeObserver({ ref }); 193 | ``` 194 | 195 | You can even reuse the same hook instance to measure different elements: 196 | 197 | [CodeSandbox Demo](https://codesandbox.io/s/use-resize-observer-reusing-refs-buftd) 198 | 199 | ## Measuring a raw element 200 | 201 | There might be situations where you have an element already that you need to measure. 202 | `ref` now accepts elements as well, not just refs, which means that you can do this: 203 | 204 | ```ts 205 | const { width, height } = useResizeObserver({ 206 | ref: divElement, 207 | }); 208 | ``` 209 | 210 | ## Using a Single Hook to Measure Multiple Refs 211 | 212 | The hook reacts to ref changes, as it resolves it to an element to observe. 213 | This means that you can freely change the custom `ref` option from one ref to 214 | another and back, and the hook will start observing whatever is set in its options. 215 | 216 | ## Opting Out of (or Delaying) ResizeObserver Instantiation 217 | 218 | In certain cases you might want to delay creating a ResizeObserver instance. 219 | 220 | You might provide a library, that only optionally provides observation features 221 | based on props, which means that while you have the hook within your component, 222 | you might not want to actually initialise it. 223 | 224 | Another example is that you might want to entirely opt out of initialising, when 225 | you run some tests, where the environment does not provide the `ResizeObserver`. 226 | 227 | ([See discussions](https://github.com/ZeeCoder/use-resize-observer/issues/40)) 228 | 229 | You can do one of the following depending on your needs: 230 | 231 | - Use the default `ref` RefCallback, or provide a custom ref conditionally, 232 | only when needed. The hook will not create a ResizeObserver instance up until 233 | there's something there to actually observe. 234 | - Patch the test environment, and make a polyfill available as the ResizeObserver. 235 | (This assumes you don't already use the polyfilled version, which would switch 236 | to the polyfill when no native implementation was available.) 237 | 238 | ## The "onResize" Callback 239 | 240 | By the default the hook will trigger a re-render on all changes to the target 241 | element's width and / or height. 242 | 243 | You can opt out of this behaviour, by providing an `onResize` callback function, 244 | which'll simply receive the width and height of the element when it changes, so 245 | that you can decide what to do with it: 246 | 247 | ```tsx 248 | import React from "react"; 249 | import useResizeObserver from "use-resize-observer"; 250 | 251 | const App = () => { 252 | // width / height will not be returned here when the onResize callback is present 253 | const { ref } = useResizeObserver({ 254 | onResize: ({ width, height }) => { 255 | // do something here. 256 | }, 257 | }); 258 | 259 | return
; 260 | }; 261 | ``` 262 | 263 | This callback also makes it possible to implement your own hooks that report only 264 | what you need, for example: 265 | 266 | - Reporting only width or height 267 | - Throttle / debounce 268 | - Wrap in `requestAnimationFrame` 269 | 270 | ## Hook Composition 271 | 272 | As this hook intends to remain low-level, it is encouraged to build on top of it via hook composition, if additional features are required. 273 | 274 | ### Throttle / Debounce 275 | 276 | You might want to receive values less frequently than changes actually occur. 277 | 278 | [CodeSandbox Demo](https://codesandbox.io/s/use-resize-observer-throttle-and-debounce-8uvsg) 279 | 280 | ### Breakpoints 281 | 282 | Another popular concept are breakpoints. Here is an example for a simple hook accomplishing that. 283 | 284 | [CodeSandbox Demo](https://codesandbox.io/s/use-resize-observer-breakpoints-3hiv8) 285 | 286 | ## Defaults (SSR) 287 | 288 | On initial mount the ResizeObserver will take a little time to report on the 289 | actual size. 290 | 291 | Until the hook receives the first measurement, it returns `undefined` for width 292 | and height by default. 293 | 294 | You can override this behaviour, which could be useful for SSR as well. 295 | 296 | ```ts 297 | const { ref, width = 100, height = 50 } = useResizeObserver(); 298 | ``` 299 | 300 | Here "width" and "height" will be 100 and 50 respectively, until the 301 | ResizeObserver kicks in and reports the actual size. 302 | 303 | ## Without Defaults 304 | 305 | If you only want real measurements (only values from the ResizeObserver without 306 | any default values), then you can just leave defaults off: 307 | 308 | ```ts 309 | const { ref, width, height } = useResizeObserver(); 310 | ``` 311 | 312 | Here "width" and "height" will be undefined until the ResizeObserver takes its 313 | first measurement. 314 | 315 | ## Container/Element Query with CSS-in-JS 316 | 317 | It's possible to apply styles conditionally based on the width / height of an 318 | element using a CSS-in-JS solution, which is the basic idea behind 319 | container/element queries: 320 | 321 | [CodeSandbox Demo](https://codesandbox.io/s/use-resize-observer-container-query-with-css-in-js-iitxl) 322 | 323 | ## Transpilation / Polyfilling 324 | 325 | By default the library provides transpiled ES5 modules in CJS / ESM module formats. 326 | 327 | Polyfilling is recommended to be done in the host app, and not within imported 328 | libraries, as that way consumers have control over the exact polyfills being used. 329 | 330 | That said, there's a [polyfilled](https://github.com/juggle/resize-observer) 331 | CJS module that can be used for convenience: 332 | 333 | ```ts 334 | import useResizeObserver from "use-resize-observer/polyfilled"; 335 | ``` 336 | 337 | Note that using the above will use the polyfill, [even if the native ResizeObserver is available](https://github.com/juggle/resize-observer#basic-usage). 338 | 339 | To use the polyfill as a fallback only when the native RO is unavailable, you can polyfill yourself instead, 340 | either in your app's entry file, or you could create a local `useResizeObserver` module, like so: 341 | 342 | ```ts 343 | // useResizeObserver.ts 344 | import { ResizeObserver } from "@juggle/resize-observer"; 345 | import useResizeObserver from "use-resize-observer"; 346 | 347 | if (!window.ResizeObserver) { 348 | window.ResizeObserver = ResizeObserver; 349 | } 350 | 351 | export default useResizeObserver; 352 | ``` 353 | 354 | The same technique can also be used to provide any of your preferred ResizeObserver polyfills out there. 355 | 356 | ## Related 357 | 358 | - [@zeecoder/container-query](https://github.com/ZeeCoder/container-query) 359 | - [@zeecoder/react-resize-observer](https://github.com/ZeeCoder/react-resize-observer) 360 | 361 | ## License 362 | 363 | MIT 364 | -------------------------------------------------------------------------------- /src/utils/useResolvedElement.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef } from "react"; 2 | import { act, render } from "@testing-library/react"; 3 | import useResolvedElement from "./useResolvedElement"; 4 | import useRenderTrigger from "../../tests/utils/useRenderTrigger"; 5 | 6 | test("should receive the element with the provided callback ref", () => { 7 | let renderCount = 0; 8 | const elements: Element[] = []; 9 | const cleanupMock = jest.fn(); 10 | const Test = () => { 11 | renderCount++; 12 | const ref = useResolvedElement( 13 | useCallback((element: Element) => { 14 | elements.push(element); 15 | return cleanupMock; 16 | }, []) 17 | ); 18 | 19 | return
; 20 | }; 21 | 22 | const { rerender } = render(); 23 | expect(renderCount).toBe(1); 24 | expect(elements.length).toBe(1); 25 | 26 | act(() => { 27 | rerender(<>); 28 | }); 29 | expect(renderCount).toBe(1); 30 | expect(cleanupMock).toHaveBeenCalledTimes(1); 31 | expect(cleanupMock).toHaveBeenCalledWith(); 32 | }); 33 | 34 | test("should be able to reuse the callback ref to get different elements", () => { 35 | let renderCount = 0; 36 | const elements: Element[] = []; 37 | const cleanupMock = jest.fn(); 38 | 39 | const Test = ({ swap }: { swap?: boolean }) => { 40 | renderCount++; 41 | const ref = useResolvedElement( 42 | useCallback((element: Element) => { 43 | elements.push(element); 44 | return cleanupMock; 45 | }, []) 46 | ); 47 | 48 | if (swap) { 49 | return ; 50 | } 51 | 52 | return
; 53 | }; 54 | 55 | const { rerender } = render(); 56 | expect(renderCount).toBe(1); 57 | expect(elements.length).toBe(1); 58 | 59 | act(() => { 60 | rerender(); 61 | }); 62 | expect(renderCount).toBe(2); 63 | expect(cleanupMock).toHaveBeenCalledTimes(1); 64 | expect(cleanupMock).toHaveBeenCalledWith(); 65 | expect(elements.length).toBe(2); 66 | expect(elements[0]).not.toBe(elements[1]); 67 | 68 | act(() => { 69 | rerender(<>); 70 | }); 71 | expect(renderCount).toBe(2); 72 | expect(cleanupMock).toHaveBeenCalledTimes(2); 73 | expect(cleanupMock).toHaveBeenCalledWith(); 74 | }); 75 | 76 | test("should be able to use a raw element", () => { 77 | const element = document.createElement("div"); 78 | let renderCount = 0; 79 | const elements: Element[] = []; 80 | const cleanupMock = jest.fn(); 81 | const Test = () => { 82 | renderCount++; 83 | useResolvedElement( 84 | useCallback((element: Element) => { 85 | elements.push(element); 86 | return cleanupMock; 87 | }, []), 88 | element 89 | ); 90 | 91 | return null; 92 | }; 93 | 94 | const { rerender } = render(); 95 | expect(renderCount).toBe(1); 96 | expect(elements.length).toBe(1); 97 | expect(elements[0]).toBe(element); 98 | 99 | act(() => { 100 | rerender(<>); 101 | }); 102 | expect(renderCount).toBe(1); 103 | expect(cleanupMock).toHaveBeenCalledTimes(1); 104 | expect(cleanupMock).toHaveBeenCalledWith(); 105 | }); 106 | 107 | test("should be able to use a ref object", () => { 108 | let renderCount = 0; 109 | const elements: Element[] = []; 110 | const cleanupMock = jest.fn(); 111 | const Test = () => { 112 | renderCount++; 113 | const ref = useRef(null); 114 | useResolvedElement( 115 | useCallback((element: Element) => { 116 | elements.push(element); 117 | return cleanupMock; 118 | }, []), 119 | ref 120 | ); 121 | 122 | return
; 123 | }; 124 | 125 | const { rerender } = render(); 126 | expect(renderCount).toBe(1); 127 | expect(elements.length).toBe(1); 128 | 129 | act(() => { 130 | rerender(<>); 131 | }); 132 | expect(renderCount).toBe(1); 133 | expect(cleanupMock).toHaveBeenCalledTimes(1); 134 | expect(cleanupMock).toHaveBeenCalledWith(); 135 | }); 136 | 137 | test("should prioritise the ref callback over a ref object argument", () => { 138 | let renderCount = 0; 139 | const elements: Element[] = []; 140 | const cleanupMock = jest.fn(); 141 | const Test = () => { 142 | renderCount++; 143 | const refObject = useRef(null); 144 | const refCallback = useResolvedElement( 145 | useCallback((element: Element) => { 146 | elements.push(element); 147 | return cleanupMock; 148 | }, []), 149 | refObject 150 | ); 151 | 152 | return ( 153 | <> 154 |
155 | 156 | 157 | ); 158 | }; 159 | 160 | const { rerender } = render(); 161 | expect(renderCount).toBe(1); 162 | expect(elements.length).toBe(1); 163 | expect(elements[0].tagName).toBe("DIV"); 164 | 165 | act(() => { 166 | rerender(<>); 167 | }); 168 | expect(renderCount).toBe(1); 169 | // The reason the span is reported when the component unmounts is because on unmount the ref callback is called with a null value. 170 | // This means that the hook now receives nothing from the ref callback, and an element via the ref object, so it reports the latter. 171 | // As the switch happens, the cleanup function is called, then on unmount cleanup is called once more. 172 | expect(elements.length).toBe(2); 173 | expect(elements[0].tagName).toBe("DIV"); 174 | expect(elements[1].tagName).toBe("SPAN"); 175 | expect(cleanupMock).toHaveBeenCalledTimes(2); 176 | expect(cleanupMock).toHaveBeenCalledWith(); 177 | }); 178 | 179 | test("should prioritise the ref callback over an element argument", () => { 180 | let renderCount = 0; 181 | const elements: Element[] = []; 182 | const cleanupMock = jest.fn(); 183 | const element = document.createElement("span"); 184 | const Test = () => { 185 | renderCount++; 186 | const refCallback = useResolvedElement( 187 | useCallback((element: Element) => { 188 | elements.push(element); 189 | return cleanupMock; 190 | }, []), 191 | element 192 | ); 193 | 194 | return
; 195 | }; 196 | 197 | const { rerender } = render(); 198 | expect(renderCount).toBe(1); 199 | expect(elements.length).toBe(1); 200 | expect(elements[0].tagName).toBe("DIV"); 201 | 202 | act(() => { 203 | rerender(<>); 204 | }); 205 | // The explanation for this behaviour is the same as above. 206 | expect(renderCount).toBe(1); 207 | expect(elements.length).toBe(2); 208 | expect(elements[0].tagName).toBe("DIV"); 209 | expect(elements[1].tagName).toBe("SPAN"); 210 | expect(cleanupMock).toHaveBeenCalledTimes(2); 211 | expect(cleanupMock).toHaveBeenCalledWith(); 212 | }); 213 | 214 | test("should be able to switch from a ref callback to a ref object", () => { 215 | let renderCount = 0; 216 | const elements: Element[] = []; 217 | // Tracking elements for cleanups for this test to assert that the right cleanup functions are called in the right order. 218 | const cleanupsDone: Element[] = []; 219 | const Test = ({ switchToRefObject }: { switchToRefObject?: boolean }) => { 220 | renderCount++; 221 | const refObject = useRef(null); 222 | const refCallback = useResolvedElement( 223 | useCallback((element: Element) => { 224 | elements.push(element); 225 | return () => cleanupsDone.push(element); 226 | }, []), 227 | refObject 228 | ); // ref object is ignored until the ref callback provides a value 229 | 230 | useEffect(() => { 231 | if (switchToRefObject) { 232 | refCallback(null); 233 | } 234 | }, [switchToRefObject, refCallback]); 235 | 236 | return ( 237 | <> 238 |
239 | 240 | 241 | ); 242 | }; 243 | 244 | const { rerender } = render(); 245 | expect(renderCount).toBe(1); 246 | expect(elements.length).toBe(1); 247 | expect(elements[0].tagName).toBe("DIV"); 248 | 249 | act(() => { 250 | rerender(); 251 | }); 252 | 253 | expect(renderCount).toBe(2); 254 | expect(cleanupsDone.length).toBe(1); 255 | expect(cleanupsDone[0]).toBe(elements[0]); 256 | expect(elements.length).toBe(2); 257 | expect(elements[0].tagName).toBe("DIV"); 258 | expect(elements[1].tagName).toBe("SPAN"); 259 | 260 | act(() => { 261 | rerender(<>); 262 | }); 263 | expect(renderCount).toBe(2); 264 | expect(elements.length).toBe(2); 265 | expect(cleanupsDone.length).toBe(2); 266 | expect(cleanupsDone[0]).toBe(elements[0]); 267 | expect(cleanupsDone[1]).toBe(elements[1]); 268 | }); 269 | 270 | test("should be able to switch from a ref object to a ref callback", () => { 271 | let renderCount = 0; 272 | const elements: Element[] = []; 273 | // Tracking elements for cleanups for this test to assert that the right cleanup functions are called in the right order. 274 | const cleanupsDone: Element[] = []; 275 | const Test = ({ switchToRefCallback }: { switchToRefCallback?: boolean }) => { 276 | renderCount++; 277 | const ref1 = useRef(null); 278 | const ref2 = useRef(null); 279 | const refCallback = useResolvedElement( 280 | useCallback((element: Element) => { 281 | elements.push(element); 282 | return () => cleanupsDone.push(element); 283 | }, []), 284 | ref1 285 | ); 286 | 287 | useEffect(() => { 288 | if (switchToRefCallback) { 289 | refCallback(ref2.current); 290 | } 291 | }, [switchToRefCallback, refCallback]); 292 | 293 | return ( 294 | <> 295 |
296 | 297 | 298 | ); 299 | }; 300 | 301 | const { rerender } = render(); 302 | expect(renderCount).toBe(1); 303 | expect(elements.length).toBe(1); 304 | expect(elements[0].tagName).toBe("DIV"); 305 | 306 | act(() => { 307 | rerender(); 308 | }); 309 | 310 | expect(renderCount).toBe(2); 311 | expect(cleanupsDone.length).toBe(1); 312 | expect(cleanupsDone[0]).toBe(elements[0]); 313 | expect(elements.length).toBe(2); 314 | expect(elements[0].tagName).toBe("DIV"); 315 | expect(elements[1].tagName).toBe("SPAN"); 316 | 317 | act(() => { 318 | rerender(<>); 319 | }); 320 | expect(renderCount).toBe(2); 321 | expect(elements.length).toBe(2); 322 | expect(cleanupsDone.length).toBe(2); 323 | expect(cleanupsDone[0]).toBe(elements[0]); 324 | expect(cleanupsDone[1]).toBe(elements[1]); 325 | }); 326 | 327 | test("should be able to switch back and forth between a ref object and a ref callback", () => { 328 | let renderCount = 0; 329 | const elements: Element[] = []; 330 | // Tracking elements for cleanups for this test to assert that the right cleanup functions are called in the right order. 331 | const cleanupsDone: Element[] = []; 332 | const Test = ({ renderSpan }: { renderSpan?: boolean }) => { 333 | renderCount++; 334 | const refObject = useRef(null); 335 | const refCallback = useResolvedElement( 336 | useCallback((element: Element) => { 337 | elements.push(element); 338 | 339 | return () => cleanupsDone.push(element); 340 | }, []), 341 | refObject 342 | ); 343 | 344 | return ( 345 | <> 346 | {renderSpan ? : null} 347 |
348 | 349 | ); 350 | }; 351 | 352 | const { rerender } = render(); 353 | expect(renderCount).toBe(1); 354 | expect(elements.length).toBe(1); 355 | expect(elements[0].tagName).toBe("DIV"); 356 | 357 | act(() => { 358 | rerender(); 359 | }); 360 | expect(renderCount).toBe(2); 361 | expect(elements.length).toBe(2); 362 | expect(elements[0].tagName).toBe("DIV"); 363 | expect(elements[1].tagName).toBe("SPAN"); 364 | 365 | act(() => { 366 | rerender(); 367 | }); 368 | expect(renderCount).toBe(3); 369 | expect(elements.length).toBe(3); 370 | expect(elements[0].tagName).toBe("DIV"); 371 | expect(elements[1].tagName).toBe("SPAN"); 372 | expect(elements[2].tagName).toBe("DIV"); 373 | 374 | act(() => { 375 | rerender(<>); 376 | }); 377 | 378 | expect(renderCount).toBe(3); 379 | expect(elements.length).toBe(3); 380 | expect(cleanupsDone.length).toBe(3); 381 | expect(cleanupsDone[0]).toBe(elements[0]); 382 | expect(cleanupsDone[1]).toBe(elements[1]); 383 | expect(cleanupsDone[2]).toBe(elements[2]); 384 | }); 385 | 386 | test("should not unnecessarily call the subscriber between renders", () => { 387 | let renderCount = 0; 388 | const elements: Element[] = []; 389 | let triggerRender: ReturnType; 390 | const Test = () => { 391 | renderCount++; 392 | const refCallback = useResolvedElement( 393 | useCallback((element: Element) => { 394 | elements.push(element); 395 | }, []) 396 | ); 397 | triggerRender = useRenderTrigger(); 398 | 399 | return
; 400 | }; 401 | 402 | render(); 403 | expect(renderCount).toBe(1); 404 | expect(elements.length).toBe(1); 405 | expect(elements[0].tagName).toBe("DIV"); 406 | 407 | act(() => { 408 | triggerRender(); 409 | }); 410 | 411 | expect(renderCount).toBe(2); 412 | expect(elements.length).toBe(1); 413 | expect(elements[0].tagName).toBe("DIV"); 414 | }); 415 | 416 | test("should call the subscriber function if its identity changes, even if the element didn't with ref callback", () => { 417 | let renderCount = 0; 418 | const elements1: Element[] = []; 419 | const elements2: Element[] = []; 420 | const cleanupMock1 = jest.fn(); 421 | const cleanupMock2 = jest.fn(); 422 | const Test = ({ switchToSubscriber2 }: { switchToSubscriber2?: boolean }) => { 423 | renderCount++; 424 | const subscriber1 = useCallback((element: Element) => { 425 | elements1.push(element); 426 | return cleanupMock1; 427 | }, []); 428 | const subscriber2 = useCallback((element: Element) => { 429 | elements2.push(element); 430 | return cleanupMock2; 431 | }, []); 432 | const refCallback = useResolvedElement( 433 | switchToSubscriber2 ? subscriber2 : subscriber1 434 | ); 435 | 436 | return
; 437 | }; 438 | 439 | const { rerender } = render(); 440 | expect(renderCount).toBe(1); 441 | expect(elements1.length).toBe(1); 442 | expect(elements1[0].tagName).toBe("DIV"); 443 | expect(elements2.length).toBe(0); 444 | 445 | act(() => { 446 | rerender(); 447 | }); 448 | 449 | expect(renderCount).toBe(2); 450 | expect(elements1.length).toBe(1); 451 | expect(elements2.length).toBe(1); 452 | expect(elements1[0].tagName).toBe("DIV"); 453 | expect(elements2[0].tagName).toBe("DIV"); 454 | expect(elements1[0]).toBe(elements2[0]); 455 | }); 456 | 457 | test("should call the subscriber function if its identity changes, even if the element didn't with ref object", () => { 458 | let renderCount = 0; 459 | const elements1: Element[] = []; 460 | const elements2: Element[] = []; 461 | const cleanupMock1 = jest.fn(); 462 | const cleanupMock2 = jest.fn(); 463 | const Test = ({ switchToSubscriber2 }: { switchToSubscriber2?: boolean }) => { 464 | renderCount++; 465 | const refObject = useRef(null); 466 | const subscriber1 = useCallback((element: Element) => { 467 | elements1.push(element); 468 | return cleanupMock1; 469 | }, []); 470 | const subscriber2 = useCallback((element: Element) => { 471 | elements2.push(element); 472 | return cleanupMock2; 473 | }, []); 474 | useResolvedElement( 475 | switchToSubscriber2 ? subscriber2 : subscriber1, 476 | refObject 477 | ); 478 | 479 | return
; 480 | }; 481 | 482 | const { rerender } = render(); 483 | expect(renderCount).toBe(1); 484 | expect(elements1.length).toBe(1); 485 | expect(elements1[0].tagName).toBe("DIV"); 486 | expect(elements2.length).toBe(0); 487 | 488 | act(() => { 489 | rerender(); 490 | }); 491 | 492 | expect(renderCount).toBe(2); 493 | expect(elements1.length).toBe(1); 494 | expect(elements2.length).toBe(1); 495 | expect(elements1[0].tagName).toBe("DIV"); 496 | expect(elements2[0].tagName).toBe("DIV"); 497 | expect(elements1[0]).toBe(elements2[0]); 498 | }); 499 | 500 | test("should call the subscriber function if its identity changes, even if the element didn't with raw element", () => { 501 | let renderCount = 0; 502 | const elements1: Element[] = []; 503 | const elements2: Element[] = []; 504 | const cleanupMock1 = jest.fn(); 505 | const cleanupMock2 = jest.fn(); 506 | const element = document.createElement("div"); 507 | const Test = ({ switchToSubscriber2 }: { switchToSubscriber2?: boolean }) => { 508 | renderCount++; 509 | const subscriber1 = useCallback((element: Element) => { 510 | elements1.push(element); 511 | return cleanupMock1; 512 | }, []); 513 | const subscriber2 = useCallback((element: Element) => { 514 | elements2.push(element); 515 | return cleanupMock2; 516 | }, []); 517 | useResolvedElement( 518 | switchToSubscriber2 ? subscriber2 : subscriber1, 519 | element 520 | ); 521 | 522 | return null; 523 | }; 524 | 525 | const { rerender } = render(); 526 | expect(renderCount).toBe(1); 527 | expect(elements1.length).toBe(1); 528 | expect(elements1[0]).toBe(element); 529 | expect(elements2.length).toBe(0); 530 | 531 | act(() => { 532 | rerender(); 533 | }); 534 | 535 | expect(renderCount).toBe(2); 536 | expect(elements1.length).toBe(1); 537 | expect(elements2.length).toBe(1); 538 | expect(elements1[0]).toBe(element); 539 | expect(elements2[0]).toBe(element); 540 | expect(elements1[0]).toBe(elements2[0]); 541 | }); 542 | 543 | test("should be able to reuse a ref callback to get a different element", () => { 544 | let renderCount = 0; 545 | const elements: Element[] = []; 546 | const cleanupsDone: Element[] = []; 547 | const Test = ({ getOtherElement }: { getOtherElement?: boolean }) => { 548 | renderCount++; 549 | const ref = useResolvedElement( 550 | useCallback((element: Element) => { 551 | elements.push(element); 552 | 553 | return () => cleanupsDone.push(element); 554 | }, []) 555 | ); 556 | 557 | if (getOtherElement) { 558 | return ; 559 | } 560 | 561 | return
; 562 | }; 563 | 564 | const { rerender } = render(); 565 | expect(renderCount).toBe(1); 566 | expect(elements.length).toBe(1); 567 | expect(elements[0].tagName).toBe("DIV"); 568 | 569 | act(() => { 570 | rerender(); 571 | }); 572 | expect(renderCount).toBe(2); 573 | expect(elements.length).toBe(2); 574 | expect(elements[0].tagName).toBe("DIV"); 575 | expect(elements[1].tagName).toBe("SPAN"); 576 | expect(cleanupsDone.length).toBe(1); 577 | expect(cleanupsDone[0]).toBe(elements[0]); 578 | 579 | act(() => { 580 | rerender(<>); 581 | }); 582 | expect(renderCount).toBe(2); 583 | expect(elements.length).toBe(2); 584 | expect(cleanupsDone.length).toBe(2); 585 | expect(cleanupsDone[0]).toBe(elements[0]); 586 | expect(cleanupsDone[1]).toBe(elements[1]); 587 | }); 588 | 589 | test("should be able to reuse a ref object to get a different element", () => { 590 | let renderCount = 0; 591 | const elements: Element[] = []; 592 | const cleanupsDone: Element[] = []; 593 | const Test = ({ getOtherElement }: { getOtherElement?: boolean }) => { 594 | renderCount++; 595 | const ref = useRef(null); 596 | useResolvedElement( 597 | useCallback((element: Element) => { 598 | elements.push(element); 599 | 600 | return () => cleanupsDone.push(element); 601 | }, []), 602 | ref 603 | ); 604 | 605 | return ( 606 | <> 607 |
608 |
609 | 610 | ); 611 | }; 612 | 613 | const { rerender } = render(); 614 | expect(renderCount).toBe(1); 615 | expect(elements.length).toBe(1); 616 | expect(elements[0].tagName).toBe("DIV"); 617 | 618 | // We remove the currently rendered div, so that we get a different element on the second subscriber call. 619 | act(() => { 620 | rerender(); 621 | }); 622 | expect(renderCount).toBe(2); 623 | expect(elements.length).toBe(2); 624 | expect(elements[0].tagName).toBe("DIV"); 625 | expect(elements[1].tagName).toBe("DIV"); 626 | expect(elements[0]).not.toBe(elements[1]); 627 | expect(cleanupsDone.length).toBe(1); 628 | expect(cleanupsDone[0]).toBe(elements[0]); 629 | 630 | act(() => { 631 | rerender(<>); 632 | }); 633 | expect(renderCount).toBe(2); 634 | expect(elements.length).toBe(2); 635 | expect(cleanupsDone.length).toBe(2); 636 | expect(cleanupsDone[0]).toBe(elements[0]); 637 | expect(cleanupsDone[1]).toBe(elements[1]); 638 | }); 639 | -------------------------------------------------------------------------------- /tests/testing-lib.tsx: -------------------------------------------------------------------------------- 1 | // Tests written with react testing library 2 | import React, { useRef, useState, useCallback } from "react"; 3 | import useResizeObserver, { 4 | ResizeHandler, 5 | ObservedSize, 6 | ResizeObserverBoxOptions, 7 | RoundingFunction, 8 | } from "../"; 9 | import { render, cleanup, act } from "@testing-library/react"; 10 | import useRenderTrigger from "./utils/useRenderTrigger"; 11 | import awaitNextFrame from "./utils/awaitNextFrame"; 12 | import createController from "./utils/createController"; 13 | import useMergedCallbackRef from "./utils/useMergedCallbackRef"; 14 | import { supports } from "./utils"; 15 | 16 | afterEach(() => { 17 | cleanup(); 18 | }); 19 | 20 | describe("Testing Lib: Basics", () => { 21 | // TODO also make sure this error doesn't happen in the console: "Warning: Can't perform a React state update on an unmounted component..." 22 | it("should measure the right sizes", async () => { 23 | const controller = createController(); 24 | 25 | const Test = () => { 26 | const { 27 | ref, 28 | width = 0, 29 | height = 0, 30 | } = useResizeObserver(); 31 | 32 | const mergedCallbackRef = useMergedCallbackRef( 33 | ref, 34 | (element: HTMLElement) => { 35 | controller.provideSetSizeFunction(element); 36 | } 37 | ); 38 | 39 | controller.incrementRenderCount(); 40 | controller.reportMeasuredSize({ width, height }); 41 | 42 | return
; 43 | }; 44 | 45 | render(); 46 | 47 | // Default response on the first render before an actual measurement took place 48 | controller.assertMeasuredSize({ width: 0, height: 0 }); 49 | controller.assertRenderCount(1); 50 | 51 | // Should react to component size changes. 52 | await act(async () => { 53 | await controller.setSize({ width: 100, height: 200 }); 54 | }); 55 | 56 | controller.assertMeasuredSize({ width: 100, height: 200 }); 57 | controller.assertRenderCount(2); 58 | }); 59 | 60 | it("should render normally in react 18 strict mode on mount", async () => { 61 | const controller = createController(); 62 | const Test = () => { 63 | const { ref, width, height } = useResizeObserver(); 64 | controller.reportMeasuredSize({ width, height }); 65 | 66 | return
; 67 | }; 68 | 69 | render( 70 | 71 | 72 | 73 | ); 74 | await act(async () => { 75 | await awaitNextFrame(); 76 | }); 77 | 78 | controller.assertMeasuredSize({ width: 100, height: 100 }); 79 | }); 80 | 81 | it("should call onResize on mount when a custom ref is used", async () => { 82 | const controller = createController(); 83 | const Test = () => { 84 | const ref = useRef(null); 85 | // Declaring onResize here only to test the availability and correctness of the exported `ResizeHandler` function 86 | const onResize: ResizeHandler = (size) => { 87 | controller.reportMeasuredSize(size); 88 | }; 89 | useResizeObserver({ 90 | ref, 91 | onResize, 92 | }); 93 | 94 | return
; 95 | }; 96 | 97 | render(); 98 | await act(async () => { 99 | await awaitNextFrame(); 100 | }); 101 | 102 | controller.assertMeasuredSize({ width: 10, height: 20 }); 103 | }); 104 | }); 105 | 106 | describe("Testing Lib: Resize Observer Instance Counting Block", () => { 107 | let resizeObserverInstanceCount = 0; 108 | let resizeObserverObserveCount = 0; 109 | let resizeObserverUnobserveCount = 0; 110 | const NativeResizeObserver = (window as any).ResizeObserver; 111 | 112 | beforeAll(() => { 113 | (window as any).ResizeObserver = function PatchedResizeObserver( 114 | cb: Function 115 | ) { 116 | resizeObserverInstanceCount++; 117 | 118 | const ro = new NativeResizeObserver(cb) as ResizeObserver; 119 | 120 | // mock 121 | return { 122 | observe: (element: Element) => { 123 | resizeObserverObserveCount++; 124 | return ro.observe(element); 125 | }, 126 | unobserve: (element: Element) => { 127 | resizeObserverUnobserveCount++; 128 | return ro.unobserve(element); 129 | }, 130 | }; 131 | }; 132 | }); 133 | 134 | beforeEach(() => { 135 | resizeObserverInstanceCount = 0; 136 | resizeObserverObserveCount = 0; 137 | resizeObserverUnobserveCount = 0; 138 | }); 139 | 140 | afterAll(() => { 141 | // Try catches fixes a Firefox issue on Travis: 142 | // https://travis-ci.org/github/ZeeCoder/use-resize-observer/builds/677364283 143 | try { 144 | (window as any).ResizeObserver = NativeResizeObserver; 145 | } catch (error) { 146 | // it's fine 147 | } 148 | }); 149 | 150 | it("should use a single ResizeObserver instance even if the onResize callback is not memoised", async () => { 151 | const controller = createController(); 152 | const Test = () => { 153 | const { ref } = useResizeObserver({ 154 | // This is only here so that each render passes a different callback 155 | // instance through to the hook. 156 | onResize: () => {}, 157 | }); 158 | 159 | controller.triggerRender = useRenderTrigger(); 160 | 161 | return
; 162 | }; 163 | 164 | render(); 165 | 166 | act(() => { 167 | controller.triggerRender(); 168 | }); 169 | 170 | // Different onResize instances used to trigger the hook's internal useEffect, 171 | // resulting in the hook using a new ResizeObserver instance on each render 172 | // regardless of what triggered it. 173 | // Now it should handle such cases and keep the previous RO instance. 174 | expect(resizeObserverInstanceCount).toBe(1); 175 | expect(resizeObserverObserveCount).toBe(1); 176 | expect(resizeObserverUnobserveCount).toBe(0); 177 | }); 178 | 179 | it("should not create a new RO instance if the hook is the same and the observed element changes", async () => { 180 | const Test = ({ observeNewElement = false }) => { 181 | const customRef = useRef(null); 182 | const { ref } = useResizeObserver({ 183 | ref: observeNewElement ? customRef : null, 184 | }); 185 | 186 | // This is a span, so that when we switch over, React actually renders a 187 | // new element used with the custom ref, which is the main point of this 188 | // test. If this were a div, then React would recycle the old element, 189 | // which is not what we want. 190 | if (observeNewElement) { 191 | return ; 192 | } 193 | 194 | return
; 195 | }; 196 | 197 | const { rerender } = render(); 198 | 199 | expect(resizeObserverInstanceCount).toBe(1); 200 | expect(resizeObserverObserveCount).toBe(1); 201 | expect(resizeObserverUnobserveCount).toBe(0); 202 | 203 | act(() => { 204 | rerender(); 205 | }); 206 | 207 | expect(resizeObserverInstanceCount).toBe(1); 208 | expect(resizeObserverObserveCount).toBe(2); 209 | // The following unobserve count assertion actually caught the cleanup 210 | // functions being called more than one times, so it's especially important 211 | // to keep this in place in order to cover that. 212 | expect(resizeObserverUnobserveCount).toBe(1); 213 | }); 214 | 215 | it("should not create a ResizeObserver instance until there's an actual element present to be measured", async () => { 216 | let renderCount = 0; 217 | let measuredWidth: number | undefined; 218 | let measuredHeight: number | undefined; 219 | const Test = ({ doMeasure }: { doMeasure: boolean }) => { 220 | const ref = useRef(null); 221 | const { width, height } = useResizeObserver({ 222 | ref: doMeasure ? ref : null, 223 | }); 224 | 225 | renderCount++; 226 | measuredWidth = width; 227 | measuredHeight = height; 228 | 229 | return
; 230 | }; 231 | 232 | const { rerender } = render(); 233 | 234 | // Default behaviour on initial mount with a null ref passed to the hook 235 | expect(resizeObserverInstanceCount).toBe(0); 236 | expect(renderCount).toBe(1); 237 | expect(measuredWidth).toBe(undefined); 238 | expect(measuredHeight).toBe(undefined); 239 | 240 | // Actually kickstarting the hook by switching from null to a real ref. 241 | await act(async () => { 242 | rerender(); 243 | }); 244 | await act(async () => { 245 | await awaitNextFrame(); 246 | }); 247 | 248 | expect(resizeObserverInstanceCount).toBe(1); 249 | expect(renderCount).toBe(3); 250 | expect(measuredWidth).toBe(100); 251 | expect(measuredHeight).toBe(200); 252 | }); 253 | 254 | // Note that even thought this sort of "works", callback refs are the preferred 255 | // method to use in such cases. Relying in this behaviour will certainly cause 256 | // issues down the line. 257 | it("should work with refs even if the ref value is filled by react later, with a delayed mount", async () => { 258 | const controller = createController(); 259 | 260 | // Mounting later. Previously this wouldn't have been picked up 261 | // automatically, and users would've had to wait for the mount, and only 262 | // then set the ref from null, to its actual object value. 263 | // @see https://github.com/ZeeCoder/use-resize-observer/issues/43#issuecomment-674719609 264 | const Test = ({ mount = false }) => { 265 | const ref = useRef(null); 266 | const { width, height } = useResizeObserver({ ref }); 267 | 268 | controller.triggerRender = useRenderTrigger(); 269 | controller.reportMeasuredSize({ width, height }); 270 | 271 | if (!mount) { 272 | return null; 273 | } 274 | 275 | return
; 276 | }; 277 | 278 | // Reported size should be undefined before the hook kicks in 279 | const { rerender } = render(); 280 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 281 | 282 | // Once the hook supposedly kicked in, it should still be undefined, as the ref is not in use yet. 283 | await awaitNextFrame(); 284 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 285 | 286 | // Once mounted, the ref *will* be filled in the next render. However, the 287 | // hook has no way of knowing about this, until there's another render call, 288 | // where it gets to compare the current values between the previous and 289 | // current render. 290 | await awaitNextFrame(); 291 | rerender(); 292 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 293 | 294 | // Once that render happened, the hook finally gets a chance to measure the element. 295 | await awaitNextFrame(); 296 | await controller.triggerRender(); 297 | controller.assertMeasuredSize({ width: 100, height: 200 }); 298 | }); 299 | 300 | // This is the proper way of handling refs where the component mounts with a delay 301 | it("should pick up on delayed mounts", async () => { 302 | const controller = createController(); 303 | 304 | // Mounting later. Previously this wouldn't have been picked up 305 | // automatically, and users would've had to wait for the mount, and only 306 | // then set the ref from null, to its actual object value. 307 | // @see https://github.com/ZeeCoder/use-resize-observer/issues/43#issuecomment-674719609 308 | const Test = ({ mount = false }) => { 309 | const { ref, width, height } = useResizeObserver(); 310 | 311 | controller.reportMeasuredSize({ width, height }); 312 | 313 | if (!mount) { 314 | return null; 315 | } 316 | 317 | return
; 318 | }; 319 | 320 | // Reported size should be undefined before the hook kicks in 321 | const { rerender } = render(); 322 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 323 | 324 | // Once the hook supposedly kicked in, it should still be undefined, as the ref is not in use yet. 325 | await awaitNextFrame(); 326 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 327 | 328 | // Once mounted, the hook should automatically pick the new element up with 329 | // the RefCallback. 330 | await act(async () => { 331 | rerender(); 332 | }); 333 | await act(async () => { 334 | await awaitNextFrame(); 335 | }); 336 | controller.assertMeasuredSize({ width: 100, height: 200 }); 337 | }); 338 | 339 | it("should work on a normal mount", async () => { 340 | const controller = createController(); 341 | const Test = () => { 342 | const { ref, width, height } = useResizeObserver(); 343 | 344 | controller.reportMeasuredSize({ width, height }); 345 | 346 | return
; 347 | }; 348 | 349 | act(() => { 350 | render(); 351 | }); 352 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 353 | 354 | await act(async () => { 355 | await awaitNextFrame(); 356 | }); 357 | controller.assertMeasuredSize({ width: 100, height: 200 }); 358 | }); 359 | 360 | it("should work with a regular element as the 'custom ref' too", async () => { 361 | const controller = createController(); 362 | const Test = () => { 363 | // This is a bit of a roundabout way of simulating the case where we have 364 | // an Element from somewhere, when we can't simply use a RefCallback. 365 | const [element, setElement] = useState(null); 366 | const { width, height } = useResizeObserver({ 367 | ref: element, 368 | }); 369 | 370 | // Interestingly, if this callback is not memoised, then on each render, 371 | // the callback is called with "null", then again with the element. 372 | const receiveElement = useCallback((element: HTMLDivElement) => { 373 | setElement(element); 374 | }, []); 375 | 376 | controller.reportMeasuredSize({ width, height }); 377 | 378 | return
; 379 | }; 380 | 381 | render(); 382 | controller.assertMeasuredSize({ width: undefined, height: undefined }); 383 | 384 | await act(async () => { 385 | await awaitNextFrame(); 386 | }); 387 | controller.assertMeasuredSize({ width: 100, height: 200 }); 388 | }); 389 | 390 | // todo separate box option testing to a separate describe block 391 | // This test will also make sure that firefox works, where the reported sizes are not returned in an array. 392 | it("should support switching back-and-forth between box types", async () => { 393 | const c1 = createController(); 394 | type Controller = { 395 | setBox: (box: ResizeObserverBoxOptions) => Promise; 396 | }; 397 | const c2 = {} as Controller; 398 | 399 | const Test = () => { 400 | const [box, setBox] = useState("border-box"); 401 | c2.setBox = useCallback(async (box) => { 402 | setBox(box); 403 | }, []); 404 | const { ref, width, height } = useResizeObserver({ box }); 405 | 406 | const mergedCallbackRef = useMergedCallbackRef( 407 | ref, 408 | (element: HTMLElement) => { 409 | c1.provideSetSizeFunction(element); 410 | } 411 | ); 412 | 413 | c1.incrementRenderCount(); 414 | c1.reportMeasuredSize({ width, height }); 415 | 416 | return ( 417 |
421 | ); 422 | }; 423 | 424 | render(); 425 | 426 | // Default response on the first render before an actual measurement took place 427 | c1.assertMeasuredSize({ width: undefined, height: undefined }); 428 | c1.assertRenderCount(1); 429 | 430 | // Should react to component size changes. 431 | await act(async () => { 432 | await c1.setSize({ width: 100, height: 200 }); 433 | }); 434 | 435 | // Should report border-size 436 | if (supports.borderBox) { 437 | c1.assertRenderCount(2); 438 | c1.assertMeasuredSize({ width: 142, height: 222 }); 439 | } else { 440 | // In non-supporting browser the hook would have nothing to report. 441 | c1.assertRenderCount(1); 442 | c1.assertMeasuredSize({ width: undefined, height: undefined }); 443 | } 444 | 445 | // Should be able to switch to observing content-box 446 | await act(async () => { 447 | await c2.setBox("content-box"); 448 | }); 449 | await act(async () => { 450 | await awaitNextFrame(); 451 | }); 452 | c1.assertMeasuredSize({ width: 100, height: 200 }); 453 | 454 | // 2 extra render should be happening: 455 | // - One for setting the local `box` state 456 | // - Another as a reaction to that coming from the hook, which would report the new values. 457 | if (supports.borderBox) { 458 | c1.assertRenderCount(4); 459 | } else { 460 | c1.assertRenderCount(3); 461 | } 462 | 463 | // Switching back yet again should be reported with "undefined" in non-supporting browsers. 464 | await act(async () => { 465 | await c2.setBox("border-box"); 466 | }); 467 | await act(async () => { 468 | await awaitNextFrame(); 469 | }); 470 | if (supports.borderBox) { 471 | c1.assertRenderCount(6); 472 | c1.assertMeasuredSize({ width: 142, height: 222 }); 473 | } else { 474 | // In non-supporting browser the hook would have nothing to report. 475 | c1.assertRenderCount(5); 476 | c1.assertMeasuredSize({ width: undefined, height: undefined }); 477 | } 478 | }); 479 | 480 | it("should be able to measure device pixel content box in supporting browsers", async () => { 481 | const c1 = createController(); 482 | type Controller = { 483 | setZoom: (zoom: number) => Promise; 484 | }; 485 | const c2 = {} as Controller; 486 | 487 | const Test = () => { 488 | const { ref, width, height } = useResizeObserver({ 489 | box: "device-pixel-content-box", 490 | }); 491 | const localRef = useRef(null); 492 | c2.setZoom = useCallback(async (zoom) => { 493 | if (localRef.current) { 494 | // @ts-ignore 495 | localRef.current.style.zoom = String(zoom); 496 | await awaitNextFrame(); 497 | } 498 | }, []); 499 | 500 | const mergedCallbackRef = useMergedCallbackRef( 501 | ref, 502 | (element: HTMLDivElement) => { 503 | localRef.current = element; 504 | if (localRef.current) { 505 | // @ts-ignore 506 | window.s = localRef.current.style; 507 | } 508 | c1.provideSetSizeFunction(element); 509 | } 510 | ); 511 | 512 | c1.incrementRenderCount(); 513 | c1.reportMeasuredSize({ width, height }); 514 | 515 | return
; 516 | }; 517 | 518 | render(); 519 | 520 | // Default response on the first render before an actual measurement took place 521 | c1.assertRenderCount(1); 522 | c1.assertMeasuredSize({ width: undefined, height: undefined }); 523 | 524 | await act(async () => { 525 | await c1.setSize({ width: 100, height: 200 }); 526 | }); 527 | if (supports.devicePixelContentBoxSize) { 528 | c1.assertRenderCount(2); 529 | c1.assertMeasuredSize({ 530 | width: Math.round(100 * devicePixelRatio), 531 | height: Math.round(200 * devicePixelRatio), 532 | }); 533 | } else { 534 | c1.assertRenderCount(1); 535 | c1.assertMeasuredSize({ width: undefined, height: undefined }); 536 | } 537 | }); 538 | 539 | it("should not report repeated values with the onResize callback", async () => { 540 | const c = createController(); 541 | const Test = () => { 542 | const [size, setSize] = useState({ 543 | width: undefined, 544 | height: undefined, 545 | }); 546 | const { ref } = useResizeObserver({ onResize: setSize }); 547 | 548 | const mergedCallbackRef = useMergedCallbackRef( 549 | ref, 550 | (element: HTMLDivElement) => { 551 | c.provideSetSizeFunction(element); 552 | } 553 | ); 554 | 555 | c.incrementRenderCount(); 556 | c.reportMeasuredSize(size); 557 | 558 | return
; 559 | }; 560 | 561 | render(); 562 | 563 | // Default response on the first render before an actual measurement took place 564 | c.assertRenderCount(1); 565 | c.assertMeasuredSize({ width: undefined, height: undefined }); 566 | 567 | await act(async () => { 568 | await c.setSize({ width: 100, height: 200 }); 569 | }); 570 | c.assertRenderCount(2); 571 | c.assertMeasuredSize({ width: 100, height: 200 }); 572 | 573 | await act(async () => { 574 | await c.setSize({ width: 100.2, height: 200.4 }); 575 | }); 576 | c.assertRenderCount(2); 577 | c.assertMeasuredSize({ width: 100, height: 200 }); 578 | }); 579 | 580 | it("should accept a custom rounding function, and adapt to function instance changes without unnecessary renders", async () => { 581 | const c1 = createController(); 582 | type Controller = { 583 | replaceRoundFunction: (fn: "multiply" | "unset") => void; 584 | }; 585 | const c2 = {} as Controller; 586 | const Test = () => { 587 | const [rounder, setRounder] = useState( 588 | () => Math.ceil 589 | ); 590 | const { ref, width, height } = useResizeObserver({ 591 | round: rounder, 592 | }); 593 | 594 | const mergedCallbackRef = useMergedCallbackRef( 595 | ref, 596 | (element: HTMLDivElement) => { 597 | c1.provideSetSizeFunction(element); 598 | c2.replaceRoundFunction = async (fn) => { 599 | setRounder(() => 600 | fn === "multiply" ? (n: number) => Math.round(n * 2) : undefined 601 | ); 602 | }; 603 | } 604 | ); 605 | 606 | c1.incrementRenderCount(); 607 | c1.reportMeasuredSize({ width, height }); 608 | 609 | return
; 610 | }; 611 | 612 | render(); 613 | 614 | // Default response on the first render before an actual measurement took place 615 | c1.assertRenderCount(1); 616 | c1.assertMeasuredSize({ width: undefined, height: undefined }); 617 | 618 | await act(async () => { 619 | await c1.setSize({ width: 100.1, height: 200.1 }); 620 | }); 621 | c1.assertRenderCount(2); 622 | c1.assertMeasuredSize({ width: 101, height: 201 }); 623 | 624 | // Testing normal re-renders 625 | 626 | await act(async () => { 627 | await c1.setSize({ width: 200.2, height: 300.2 }); 628 | }); 629 | c1.assertRenderCount(3); 630 | c1.assertMeasuredSize({ width: 201, height: 301 }); 631 | 632 | await act(async () => { 633 | await c2.replaceRoundFunction("multiply"); 634 | }); 635 | await act(async () => { 636 | await awaitNextFrame(); 637 | }); 638 | c1.assertRenderCount(5); 639 | c1.assertMeasuredSize({ width: 400, height: 600 }); 640 | 641 | await act(async () => { 642 | await c2.replaceRoundFunction("unset"); 643 | }); 644 | await act(async () => { 645 | await awaitNextFrame(); 646 | }); 647 | c1.assertRenderCount(7); 648 | c1.assertMeasuredSize({ width: 200, height: 300 }); 649 | }); 650 | 651 | it("should only re-render with a custom rounding function when it produces a new value", async () => { 652 | const c = createController(); 653 | // A rounding function that "snaps" to its values. 654 | const rounder = (n: number) => { 655 | if (n < 500) { 656 | return 0; 657 | } else if (n < 1000) { 658 | return 500; 659 | } 660 | 661 | return 1000; 662 | }; 663 | const Test = () => { 664 | const { ref, width, height } = useResizeObserver({ 665 | round: rounder, 666 | }); 667 | 668 | const mergedCallbackRef = useMergedCallbackRef( 669 | ref, 670 | (element: HTMLDivElement) => { 671 | c.provideSetSizeFunction(element); 672 | } 673 | ); 674 | 675 | c.incrementRenderCount(); 676 | c.reportMeasuredSize({ width, height }); 677 | 678 | return
; 679 | }; 680 | 681 | render(); 682 | 683 | // Default response on the first render before an actual measurement took place 684 | c.assertRenderCount(1); 685 | c.assertMeasuredSize({ width: undefined, height: undefined }); 686 | 687 | await act(async () => { 688 | await c.setSize({ width: 100, height: 100 }); 689 | }); 690 | c.assertRenderCount(2); 691 | c.assertMeasuredSize({ width: 0, height: 0 }); 692 | 693 | await act(async () => { 694 | await c.setSize({ width: 200, height: 200 }); 695 | }); 696 | c.assertRenderCount(2); 697 | c.assertMeasuredSize({ width: 0, height: 0 }); 698 | 699 | await act(async () => { 700 | await c.setSize({ width: 600, height: 600 }); 701 | }); 702 | c.assertRenderCount(3); 703 | c.assertMeasuredSize({ width: 500, height: 500 }); 704 | 705 | await act(async () => { 706 | await c.setSize({ width: 1100, height: 600 }); 707 | }); 708 | c.assertRenderCount(4); 709 | c.assertMeasuredSize({ width: 1000, height: 500 }); 710 | 711 | await act(async () => { 712 | await c.setSize({ width: 1100, height: 800 }); 713 | }); 714 | c.assertRenderCount(4); 715 | c.assertMeasuredSize({ width: 1000, height: 500 }); 716 | 717 | await act(async () => { 718 | await c.setSize({ width: 1100, height: 1100 }); 719 | }); 720 | c.assertRenderCount(5); 721 | c.assertMeasuredSize({ width: 1000, height: 1000 }); 722 | }); 723 | }); 724 | --------------------------------------------------------------------------------