diff --git a/.npmignore b/.npmignore index 069d326..d1bcaf1 100644 --- a/.npmignore +++ b/.npmignore @@ -9,6 +9,7 @@ karma.conf.js .editorconfig browserslist .cache +travis .travis.yml tests tsconfig.json diff --git a/.size-limit.json b/.size-limit.json index 761735b..b81fb17 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1,32 +1,32 @@ [ { "path": "dist/bundle.esm.js", - "limit": "357 B", + "limit": "499 B", "gzip": true }, { "path": "dist/bundle.esm.js", - "limit": "281 B", + "limit": "404 B", "brotli": true }, { "path": "dist/bundle.cjs.js", - "limit": "346 B", + "limit": "485 B", "gzip": true }, { "path": "dist/bundle.cjs.js", - "limit": "261 B", + "limit": "387 B", "brotli": true }, { "path": "polyfilled.js", - "limit": "2678 B", + "limit": "2809 B", "gzip": true }, { "path": "polyfilled.js", - "limit": "2381 B", + "limit": "2512 B", "brotli": true } ] diff --git a/.travis.yml b/.travis.yml index 302fa68..3893257 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,9 @@ addons: firefox: latest script: - yarn test +before_deploy: + - export DIST_TAG=$(node travis/getNpmDistTag.js) + - "echo NPM DIST TAG: $DIST_TAG" deploy: provider: npm email: contact@hubertviktor.com @@ -19,3 +22,4 @@ deploy: repo: ZeeCoder/use-resize-observer branch: master skip_cleanup: true + tag: ${DIST_TAG} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3249a29..a9c5c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,42 @@ # CHANGELOG +## 6.2.0-alpha.1 + +- Only instantiating a ResizeObserver instance if there's actually something to + observe. This for example means that if you pass in `null` or undefined as the + ref, or if neither the default ref or callback ref returned from the hook are + in use, then no ResizeObserver instance will get created until there's an + actual element to observe. Resolves: #42 +- The hook now returns `callbackRef`, which can be used in place of the usual + `ref`. Use this instead of a normal ref, when the observed component is + mounted with a delay. Resolves: #43, #45 +- The `ref` option now accepts raw elements as well. +- Handling custom refs (through options), the default ref and the callback ref + has been greatly refactored internally (into the `useResolvedElement` + hook), to handle more edge cases with the way refs are handled. +- Tests based on react testing library were refactored to make them much simpler + and more approachable. +- Fixed an error where in certain edge cases the hook tried to set state when + its host component already unmounted. +- Added [contributing guidelines](./CONTRIBUTING.md) +- Overall bundle size increased a bit, due to the new features added. + (With about ~150B or so.) + ## 6.1.0 - No changes, only publishing the next minor. -## 6.1.0-alpha3 +## 6.1.0-alpha.3 - Fixed SSR rendering, and added a test to cover it. -## 6.1.0-alpha2 +## 6.1.0-alpha.2 - ResizeObserver instances are no longer created unnecessarily when the onResize callback changes. (Fixes #32) - Written new tests in [react testing library](https://github.com/testing-library/react-testing-library). -## 6.1.0-alpha1 +## 6.1.0-alpha.1 - Rewrote the source in TypeScript. (Feedback is welcome.) - Rewrote tests in TypeScript as well. (Ensures the types make sense.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..58584f7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# CONTRIBUTING + +When contributing to this project, please keep in mind the following goals: + +- The hook must remain as simple as possible. It's only a "proxy" to a + ResizeObserver instance, and shouldn't add features that the resize observer + doesn't do. +- All features must be covered with test(s). + +It's also best to first submit an issue, before creating a pull request so that +the required feature can be discussed, as well as the actual implementation. + +## Adding a New Feature + +- Open an issue, so that a discussion can take place, +- Once discussed, add the changes to `src/index.ts` accordingly (make sure TS + types are respected as well), +- Add new test(s) to cover the new feature in: `test/testing-lib.tsx`. + Ignore the other test files, as they're fairly old and much harder to add new + ones to them compared to just using react testing lib. +- Run all the tests to ensure there are no regressions: `yarn test`, + +## Using Watch Modes While Developing + +There are other test-related scripts in package.json, to enhance the developer +experience. +While making changes you might want to watch the source files, and build them +automatically, as well as having Karma run a (non-headless) Chrome instance +every time a change was made. + +To do that: + +- Run `yarn src:watch` in a terminal tab +- Run `KARMA_BROWSERS=Chrome yarn karma:watch` in another. + +Don't forget to run `yarn test` at the end once you're done with everything, to +make sure the new code is tested for regressions. diff --git a/README.md b/README.md index 4826430..85dccc1 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,14 @@ A React hook that allows you to use a ResizeObserver to measure an element's siz ## Highlights - Written in **TypeScript**. -- **Tiny**: 353 B (minified, gzipped) Monitored by [size-limit](https://github.com/ai/size-limit). +- **Tiny**: 500B (minified, gzipped) Monitored by [size-limit](https://github.com/ai/size-limit). - Exposes an **onResize callback** if you need more control. -- [Throttle / Debounce](#throttle--debounce) - Works with **SSR**. - Works with **CSS-in-JS**. - **Supports custom refs** in case you [had one already](#passing-in-your-own-ref). +- Handles many edge cases you might not even think of. + (See this documentation and the test cases.) +- [Throttle / Debounce](#throttle--debounce) - **Tested** in real browsers. (Headless Chrome and Firefox). ## In Action @@ -40,12 +42,12 @@ npm install use-resize-observer --save-dev Note that the default builds are not polyfilled! For instructions and alternatives, see the [Transpilation / Polyfilling](#transpilation--polyfilling) section. -```js +```tsx import React from "react"; import useResizeObserver from "use-resize-observer"; const App = () => { - const { ref, width = 1, height = 1 } = useResizeObserver(); + const { ref, width = 1, height = 1 } = useResizeObserver(); return (
@@ -60,15 +62,92 @@ const App = () => { You can pass in your own ref instead of using the one provided. This can be useful if you already have a ref you want to measure. -```js -const ref = useRef(null); -const { width, height } = useResizeObserver({ ref }); +```ts +const ref = useRef(null); +const { width, height } = useResizeObserver({ ref }); ``` You can even reuse the same hook instance to measure different elements: [CodeSandbox Demo](https://codesandbox.io/s/use-resize-observer-reusing-refs-buftd) +## Measuring a raw element + +There might be situations where you have an element already that you need to measure. +`ref` now accepts elements as well, not just refs, which means that you can do this: + +```ts +const { width, height } = useResizeObserver({ + ref: divElement, +}); +``` + +## Observing components mounted with a delay + +Often times you might have to wait before you can mount a component. +Unfortunately if you use a ref, the hook will not be able to pick up its "current" +value being changed afterwards. (Which is by design by React.) + +To handle this case, you can do one of these three: + +- Use the provided callback ref +- Refactor to a component which you can mount after a "loading" concluded with + the hook within it. This means that the hook will be mounted with the measured + element, which means that a regular ref will work fine. +- Use local state to store the custom ref the hook needs to observe, with "null" + as its starting value. Then once loading concluded, set this state to the ref. + This'll make the hook notice a change from null to the ref, where this time the + ref actually has its current value set properly as well. + ([See example here](https://codesandbox.io/s/damp-cookies-6bdrg?file=/src/App.js)) + +Using a callback ref is recommended, and might look like this: + +```tsx +const { callbackRef, width, height } = useResizeObserver(); +const [loaded, setLoaded] = useState(false); + +// Simulating a loading state. +useEffect(() => { + setTimeout(() => { + setLoaded(true); + }, 2000); +}, []); + +if (!loaded) { + return
Loading...
; +} + +return
; +``` + +## Using a single hook to measure multiple refs + +The hook reacts to ref changes, as it resolves it to an element to observe. +This means that you can freely change the custom `ref` option from one ref to +another and back, and the hook will start observing whatever is set in its options. + +## Opting Out of (or Delaying) ResizeObserver instantiation + +In certain cases you might want to delay creating a ResizeObserver instance. + +You might provide a library, that only optionally provides observation features +based on props, which means that while you have the hook within your component, +you might not want to actually initialise it. + +Another example is that you might want to entirely opt out of initialising, when +you run some tests, where the environment does not provide the `ResizeObserver`. + +([See discussions](https://github.com/ZeeCoder/use-resize-observer/issues/40)) + +You can do one of the following depending on your needs: + +- Use a callback ref, or provide a custom ref conditionally, only when needed. + The hook will not create a ResizeObserver instance up until there's something + there to actually observe. +- Patch the test environment, and make a polyfill available as the ResizeObserver. + (This assumes you don't already use the polyfilled version, which would switch + to the polyfill when no native implementation was available.) + ## The "onResize" callback By the default the hook will trigger a re-render on all changes to the target @@ -78,13 +157,13 @@ You can opt out of this behaviour, by providing an `onResize` callback function, which'll simply receive the width and height of the element when it changes, so that you can decide what to do with it: -```js +```tsx import React from "react"; import useResizeObserver from "use-resize-observer"; const App = () => { // width / height will not be returned here when the onResize callback is present - const { ref } = useResizeObserver({ + const { ref } = useResizeObserver({ onResize: ({ width, height }) => { // do something here. }, @@ -119,8 +198,8 @@ and height by default. You can override this behaviour, which could be useful for SSR as well. -```js -const { ref, width = 100, height = 50 } = useResizeObserver(); +```ts +const { ref, width = 100, height = 50 } = useResizeObserver(); ``` Here "width" and "height" will be 100 and 50 respectively, until the @@ -131,8 +210,8 @@ ResizeObserver kicks in and reports the actual size. If you only want real measurements (only values from the ResizeObserver without any default values), then you can just leave defaults off: -```js -const { ref, width, height } = useResizeObserver(); +```ts +const { ref, width, height } = useResizeObserver(); ``` Here "width" and "height" will be undefined until the ResizeObserver takes its diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..b005556 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,3 @@ +# Development + +Various development notes. diff --git a/karma.conf.js b/karma.conf.js index 8b17e8f..b68fa24 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -2,6 +2,7 @@ module.exports = function (config) { const browsers = (process.env.KARMA_BROWSERS || "ChromeHeadless").split(","); const testFilePattern = "tests/*.tsx"; + // const testFilePattern = "tests/testing-lib.tsx"; config.set({ basePath: ".", diff --git a/package.json b/package.json index a58af6b..5598e8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "use-resize-observer", - "version": "6.1.0", + "version": "6.2.0-alpha.1", "main": "dist/bundle.cjs.js", "module": "dist/bundle.esm.js", "types": "dist/index.d.ts", @@ -45,9 +45,10 @@ "@babel/preset-env": "^7.7.7", "@babel/preset-react": "^7.9.4", "@babel/preset-typescript": "^7.9.0", + "@rollup/plugin-babel": "^5.2.1", "@rollup/plugin-inject": "^4.0.1", "@size-limit/preset-small-lib": "^4.4.5", - "@testing-library/react": "^10.0.2", + "@testing-library/react": "^11.0.4", "@types/karma": "^5.0.0", "@types/karma-jasmine": "^3.1.0", "@types/react": "^16.9.34", @@ -58,7 +59,7 @@ "karma": "^5.0.1", "karma-chrome-launcher": "^3.0.0", "karma-firefox-launcher": "^1.3.0", - "karma-jasmine": "^3.1.1", + "karma-jasmine": "^4.0.1", "karma-sourcemap-loader": "^0.3.7", "karma-spec-reporter": "^0.0.32", "karma-webpack": "^4.0.2", @@ -68,9 +69,8 @@ "react": "^16.9.0", "react-dom": "^16.9.0", "rollup": "^2.6.1", - "rollup-plugin-babel": "^4.4.0", "size-limit": "^4.4.5", - "typescript": "^3.8.3" + "typescript": "^4.0.3" }, "dependencies": { "resize-observer-polyfill": "^1.5.1" diff --git a/rollup.config.js b/rollup.config.js index a760af9..d919a22 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,11 +1,19 @@ -import babel from "rollup-plugin-babel"; +import babel from "@rollup/plugin-babel"; import inject from "@rollup/plugin-inject"; const getConfig = ({ polyfill = false } = {}) => { const config = { input: "src/index.ts", output: [], - plugins: [babel({ extensions: ["ts"] })], + plugins: [ + babel({ + extensions: ["ts"], + // Seems like there's not really a difference in case of this lib, but + // might worth reconsidering later to use "runtime". + // @see https://github.com/rollup/plugins/tree/master/packages/babel#babelhelpers + babelHelpers: "bundled", + }), + ], external: ["react"], }; @@ -14,6 +22,7 @@ const getConfig = ({ polyfill = false } = {}) => { { file: "polyfilled.js", format: "cjs", + exports: "default", }, ]; config.external.push("resize-observer-polyfill"); @@ -27,6 +36,7 @@ const getConfig = ({ polyfill = false } = {}) => { { file: "dist/bundle.cjs.js", format: "cjs", + exports: "default", }, { file: "dist/bundle.esm.js", diff --git a/src/index.ts b/src/index.ts index d908440..7e291e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,85 @@ import { useRef, useMemo, RefObject, - MutableRefObject, + RefCallback, + useCallback, } from "react"; +type SubscriberCleanup = () => void; +type SubscriberResponse = SubscriberCleanup | void; + +// This of course could've been more streamlined with internal state instead of +// refs, but then host hooks / components could not opt out of renders. +// This could've been exported to its own module, but the current build doesn't +// seem to work with module imports and I had no more time to spend on this... +function useResolvedElement( + subscriber: (element: T) => SubscriberResponse, + refOrElement?: T | RefObject | null +): { + ref: RefObject; + callbackRef: RefCallback; +} { + // The default ref has to be non-conditionally declared here whether or not + // it'll be used as that's how hooks work. + // @see https://reactjs.org/docs/hooks-rules.html#explanation + let ref = useRef(null); // Default ref + const refElement = useRef(null); + const callbackRefElement = useRef(null); + const callbackRef = useCallback((element: T) => { + callbackRefElement.current = element; + callSubscriber(); + }, []); + const lastReportedElementRef = useRef(null); + const cleanupRef = useRef(); + + const callSubscriber = () => { + let element = null; + if (callbackRefElement.current) { + element = callbackRefElement.current; + } else if (refElement.current) { + element = refElement.current; + } else if (refOrElement instanceof HTMLElement) { + element = refOrElement; + } + + if (lastReportedElementRef.current === element) { + return; + } + + if (cleanupRef.current) { + cleanupRef.current(); + } + lastReportedElementRef.current = element; + + // Only calling the subscriber, if there's an actual element to report. + if (element) { + cleanupRef.current = subscriber(element); + } + }; + + if (refOrElement && !(refOrElement instanceof HTMLElement)) { + // Overriding the default ref with the given one + ref = refOrElement; + } + + // On each render, we check whether a ref changed, or if we got a new raw + // element. + useEffect(() => { + // Note that this does not mean that "element" will necessarily be whatever + // the ref currently holds. It'll simply "update" `element` each render to + // the current ref value, but there's no guarantee that the ref value will + // not change later without a render. + // This may or may not be a problem depending on the specific use case. + refElement.current = ref.current; + callSubscriber(); + }, [ref, ref.current, refOrElement]); + + return { + ref, + callbackRef, + }; +} + type ObservedSize = { width: number | undefined; height: number | undefined; @@ -14,28 +90,33 @@ type ObservedSize = { type ResizeHandler = (size: ObservedSize) => void; +type HookResponse = { + ref: RefObject; + callbackRef: RefCallback; +} & ObservedSize; + // Type definition when the user wants the hook to provide the ref with the given type. function useResizeObserver(opts?: { onResize?: ResizeHandler; -}): { ref: RefObject } & ObservedSize; +}): HookResponse; // Type definition when the hook just passes through the user provided ref. function useResizeObserver(opts?: { ref: RefObject; onResize?: ResizeHandler; -}): { ref: RefObject } & ObservedSize; +}): HookResponse; -function useResizeObserver( +function useResizeObserver(opts?: { + ref: RefObject | T | null | undefined; + onResize?: ResizeHandler; +}): HookResponse; + +function useResizeObserver( opts: { - ref?: RefObject; + ref?: RefObject | T | null | undefined; onResize?: ResizeHandler; } = {} -): { ref: RefObject } & ObservedSize { - // `defaultRef` Has to be non-conditionally declared here whether or not it'll - // be used as that's how hooks work. - // @see https://reactjs.org/docs/hooks-rules.html#explanation - const defaultRef = useRef(null); - +): HookResponse { // Saving the callback as a ref. With this, I don't need to put onResize in the // effect dep array, and just passing in an anonymous function without memoising // will not reinstantiate the hook's ResizeObserver @@ -43,12 +124,9 @@ function useResizeObserver( const onResizeRef = useRef(undefined); onResizeRef.current = onResize; - // Using a single instance throughought the hook's lifetime - const resizeObserverRef = useRef() as MutableRefObject< - ResizeObserver - >; + // Using a single instance throughout the hook's lifetime + const resizeObserverRef = useRef(); - const ref = opts.ref || defaultRef; const [size, setSize] = useState<{ width?: number; height?: number; @@ -57,6 +135,15 @@ function useResizeObserver( height: undefined, }); + // In certain edge cases the RO might want to report a size change just after + // the component unmounted. + const didUnmount = useRef(false); + useEffect(() => { + return () => { + didUnmount.current = true; + }; + }, []); + // Using a ref to track the previous width / height to avoid unnecessary renders const previous: { current: { @@ -68,64 +155,60 @@ function useResizeObserver( height: undefined, }); - useEffect(() => { - if (resizeObserverRef.current) { - return; - } - - resizeObserverRef.current = new ResizeObserver((entries) => { - if (!Array.isArray(entries)) { - return; - } - - // Since we only observe the one element, we don't need to loop over the - // array - if (!entries.length) { - return; - } + // This block is kinda like a useEffect, only it's called whenever a new + // element could be resolved based on the ref option. It also has a cleanup + // function. + const { ref, callbackRef } = useResolvedElement((element) => { + // Initialising the RO instance + if (!resizeObserverRef.current) { + // Saving a single instance, used by the hook from this point on. + resizeObserverRef.current = new ResizeObserver((entries) => { + if (!Array.isArray(entries)) { + return; + } - const entry = entries[0]; - - // `Math.round` is in line with how CSS resolves sub-pixel values - const newWidth = Math.round(entry.contentRect.width); - const newHeight = Math.round(entry.contentRect.height); - if ( - previous.current.width !== newWidth || - previous.current.height !== newHeight - ) { - const newSize = { width: newWidth, height: newHeight }; - if (onResizeRef.current) { - onResizeRef.current(newSize); - } else { - previous.current.width = newWidth; - previous.current.height = newHeight; - setSize(newSize); + // Since we only observe the one element, we don't need to loop over the + // array + if (!entries.length) { + return; } - } - }); - }, []); - useEffect(() => { - if ( - typeof ref !== "object" || - ref === null || - !(ref.current instanceof Element) - ) { - return; + const entry = entries[0]; + + // `Math.round` is in line with how CSS resolves sub-pixel values + const newWidth = Math.round(entry.contentRect.width); + const newHeight = Math.round(entry.contentRect.height); + if ( + previous.current.width !== newWidth || + previous.current.height !== newHeight + ) { + const newSize = { width: newWidth, height: newHeight }; + if (onResizeRef.current) { + onResizeRef.current(newSize); + } else { + previous.current.width = newWidth; + previous.current.height = newHeight; + if (!didUnmount.current) { + setSize(newSize); + } + } + } + }); } - const element = ref.current; - resizeObserverRef.current.observe(element); - return () => resizeObserverRef.current.unobserve(element); - }, [ref]); + return () => { + if (resizeObserverRef.current) { + resizeObserverRef.current.unobserve(element); + } + }; + }, opts.ref); - return useMemo(() => ({ ref, width: size.width, height: size.height }), [ - ref, - size ? size.width : null, - size ? size.height : null, - ]); + return useMemo( + () => ({ ref, callbackRef, width: size.width, height: size.height }), + [ref, callbackRef, size ? size.width : null, size ? size.height : null] + ); } export default useResizeObserver; diff --git a/tests/basic.tsx b/tests/basic.tsx index fee11ba..e3815e7 100644 --- a/tests/basic.tsx +++ b/tests/basic.tsx @@ -1,4 +1,3 @@ -// todo test for SSR import React, { useEffect, useState, @@ -117,10 +116,7 @@ describe("Vanilla tests", () => { }) => { const ref = useRef(null); const { width, height } = useResizeObserver({ ref }); - const currentSizeRef = useRef<{ - width: number | undefined; - height: number | undefined; - }>({ width: undefined, height: undefined }); + const currentSizeRef = useRef({} as ObservedSize); currentSizeRef.current.height = height; currentSizeRef.current.width = width; diff --git a/tests/testing-lib.tsx b/tests/testing-lib.tsx index 2976c8f..21f2f57 100644 --- a/tests/testing-lib.tsx +++ b/tests/testing-lib.tsx @@ -1,102 +1,46 @@ // Tests written with react testing library -import React, { useRef, useEffect, useState } from "react"; +import React, { useRef, useState, useCallback } from "react"; import useResizeObserver from "../"; -import { render, cleanup, RenderResult } from "@testing-library/react"; -import { ObservedSize } from "./utils"; -import delay from "delay"; +import { render, cleanup } from "@testing-library/react"; +import useRenderTrigger from "./utils/useRenderTrigger"; +import awaitNextFrame from "./utils/awaitNextFrame"; +import createController from "./utils/createController"; afterEach(() => { cleanup(); }); -type ComponentController = { - setSize: (width: number, height: number) => void; - getRenderCount: () => number; - getWidth: () => number | undefined; - getHeight: () => number | undefined; - triggerRender: () => void; - switchToExplicitRef: () => void; -}; - -type TestProps = { - onResize?: (size: ObservedSize) => void; - resolveController: (controller: ComponentController) => void; -}; - -const Test = ({ onResize, resolveController }: TestProps) => { - const [, setRenderTrigger] = useState(false); - const [useExplicitRef, setUseExplicitRef] = useState(false); - const explicitRef = useRef(null); - const { ref, width = 0, height = 0 } = useResizeObserver({ - // We intentionally create a new function instance here if onResize is given. - // The hook is supposed to handle it and not recreate ResizeObserver instances on each render for example. - onResize: onResize ? (size: ObservedSize) => onResize(size) : undefined, - ...(useExplicitRef ? { ref: explicitRef } : {}), - }); - const controllerStateRef = useRef<{ renderCount: number } & ObservedSize>({ - renderCount: 0, - width: undefined, - height: undefined, - }); - - controllerStateRef.current.renderCount++; - controllerStateRef.current.width = width; - controllerStateRef.current.height = height; - - useEffect(() => { - resolveController({ - setSize: (width: number, height: number) => { - if (!ref.current) { - throw new Error(`Expected "ref.current" to be set.`); - } - - ref.current.style.width = `${width}px`; - ref.current.style.height = `${height}px`; - }, - getRenderCount: () => controllerStateRef.current.renderCount, - getWidth: () => controllerStateRef.current.width, - getHeight: () => controllerStateRef.current.height, - triggerRender: () => setRenderTrigger((value) => !value), - switchToExplicitRef: () => setUseExplicitRef(true), - }); - }, []); - - return
; -}; - -const awaitNextFrame = () => - new Promise((resolve) => setTimeout(resolve, 1000 / 60)); - -const renderTest = ( - props: Omit = {} -): Promise<[ComponentController, RenderResult]> => - new Promise((resolve) => { - const tools = render( - resolve([controller, tools])} - > - ); - }); - describe("Testing Lib: Basics", () => { + // TODO also make sure this error doesn't happen in the console: "Warning: Can't perform a React state update on an unmounted component..." it("should measure the right sizes", async () => { - const [controller] = await renderTest(); + const controller = createController(); + + const Test = () => { + const { ref, width = 0, height = 0 } = useResizeObserver< + HTMLDivElement + >(); + + controller.incrementRenderCount(); + controller.reportMeasuredSize({ width, height }); + controller.provideSetSizeFunction(ref); + + return
; + }; + + render(); // Default response on the first render before an actual measurement took place - expect(controller.getWidth()).toBe(0); - expect(controller.getHeight()).toBe(0); - expect(controller.getRenderCount()).toBe(1); + controller.assertMeasuredSize({ width: 0, height: 0 }); + controller.assertRenderCount(1); // Should react to component size changes. - controller.setSize(100, 200); - await awaitNextFrame(); - expect(controller.getWidth()).toBe(100); - expect(controller.getHeight()).toBe(200); - expect(controller.getRenderCount()).toBe(2); + await controller.setSize({ width: 100, height: 200 }); + controller.assertMeasuredSize({ width: 100, height: 200 }); + controller.assertRenderCount(2); }); }); +// todo instead of this being a separate block, just add it globally. Then each test has the option to utilise it. describe("Testing Lib: Resize Observer Instance Counting Block", () => { let resizeObserverInstanceCount = 0; let resizeObserverObserveCount = 0; @@ -111,7 +55,8 @@ describe("Testing Lib: Resize Observer Instance Counting Block", () => { const ro = new NativeResizeObserver(cb) as ResizeObserver; - const mock = { + // mock + return { observe: (element: Element) => { resizeObserverObserveCount++; return ro.observe(element); @@ -121,8 +66,6 @@ describe("Testing Lib: Resize Observer Instance Counting Block", () => { return ro.unobserve(element); }, }; - - return mock; }; }); @@ -143,46 +86,227 @@ describe("Testing Lib: Resize Observer Instance Counting Block", () => { }); it("should use a single ResizeObserver instance even if the onResize callback is not memoised", async () => { - const [controller] = await renderTest({ - // This is only here so that each render passes a different callback - // instance through to the hook. - onResize: (size) => {}, - }); + const controller = createController(); + const Test = () => { + const { ref } = useResizeObserver({ + // This is only here so that each render passes a different callback + // instance through to the hook. + onResize: () => {}, + }); - await awaitNextFrame(); + controller.triggerRender = useRenderTrigger(); + controller.provideSetSizeFunction(ref); + + return
; + }; - controller.triggerRender(); + render(); await awaitNextFrame(); + await controller.triggerRender(); // Different onResize instances used to trigger the hook's internal useEffect, // resulting in the hook using a new ResizeObserver instance on each render // regardless of what triggered it. + // Now it should handle such cases and keep the previous RO instance. expect(resizeObserverInstanceCount).toBe(1); expect(resizeObserverObserveCount).toBe(1); expect(resizeObserverUnobserveCount).toBe(0); }); - it("should not reinstantiate if the hook is the same but the observed element changes", async () => { - const [controller] = await renderTest(); + it("should not create a new RO instance if the hook is the same and the observed element changes", async () => { + const Test = ({ observeNewElement = false }) => { + const customRef = useRef(null); + const { ref } = useResizeObserver({ + ref: observeNewElement ? customRef : null, + }); + + // This is a span, so that when we switch over, React actually renders a + // new element used with the custom ref, which is the main point of this + // test. If this were a div, then React would recycle the old element, + // which is not what we want. + if (observeNewElement) { + return ; + } + + return
; + }; + + const { rerender } = render(); + await awaitNextFrame(); - // Default behaviour on initial mount with the explicit ref expect(resizeObserverInstanceCount).toBe(1); expect(resizeObserverObserveCount).toBe(1); expect(resizeObserverUnobserveCount).toBe(0); - // Switching to a different ref / element causes the hook to unobserve the - // previous element, and observe the new one, but it should not recreate the - // ResizeObserver instance. - - // The waits here are added to replicate, and address an issue with travis - // running Firefox in headless mode: - // https://travis-ci.org/github/ZeeCoder/use-resize-observer/builds/677375509 + rerender(); await awaitNextFrame(); - controller.switchToExplicitRef(); - await delay(1000); + expect(resizeObserverInstanceCount).toBe(1); expect(resizeObserverObserveCount).toBe(2); expect(resizeObserverUnobserveCount).toBe(1); }); + + it("should not create a ResizeObserver instance until there's an actual element present to be measured", async () => { + let renderCount = 0; + let measuredWidth: number | undefined; + let measuredHeight: number | undefined; + const Test = ({ doMeasure }: { doMeasure: boolean }) => { + const ref = useRef(null); + const { width, height } = useResizeObserver({ + ref: doMeasure ? ref : null, + }); + + renderCount++; + measuredWidth = width; + measuredHeight = height; + + return
; + }; + + const { rerender } = render(); + + // Default behaviour on initial mount with a null ref passed to the hook + expect(resizeObserverInstanceCount).toBe(0); + expect(renderCount).toBe(1); + expect(measuredWidth).toBe(undefined); + expect(measuredHeight).toBe(undefined); + + // Actually kickstarting the hook by switching from null to a real ref. + rerender(); + await awaitNextFrame(); + expect(resizeObserverInstanceCount).toBe(1); + expect(renderCount).toBe(3); + expect(measuredWidth).toBe(100); + expect(measuredHeight).toBe(200); + }); + + // Note that even thought this sort of "works", callback refs are the preferred + // method to use in such cases. Relying in this behaviour will certainly cause + // issues down the line. + it("should work with refs even if the ref value is filled by react later, with a delayed mount", async () => { + const controller = createController(); + + // Mounting later. Previously this wouldn't have been picked up + // automatically, and users would've had to wait for the mount, and only + // then set the ref from null, to its actual object value. + // @see https://github.com/ZeeCoder/use-resize-observer/issues/43#issuecomment-674719609 + const Test = ({ mount = false }) => { + const { ref, width, height } = useResizeObserver(); + + controller.triggerRender = useRenderTrigger(); + controller.reportMeasuredSize({ width, height }); + + if (!mount) { + return null; + } + + return
; + }; + + // Reported size should be undefined before the hook kicks in + const { rerender } = render(); + controller.assertMeasuredSize({ width: undefined, height: undefined }); + + // Once the hook supposedly kicked in, it should still be undefined, as the ref is not in use yet. + await awaitNextFrame(); + controller.assertMeasuredSize({ width: undefined, height: undefined }); + + // Once mounted, the ref *will* be filled in the next render. However, the + // hook has no way of knowing about this, until there's another render call, + // where it gets to compare the current values between the previous and + // current render. + await awaitNextFrame(); + rerender(); + controller.assertMeasuredSize({ width: undefined, height: undefined }); + + // Once that render happened, the hook finally gets a chance to measure the element. + await awaitNextFrame(); + await controller.triggerRender(); + controller.assertMeasuredSize({ width: 100, height: 200 }); + }); + + // This is the proper way of handling refs where the component mounts with a delay + it("should pick up on delayed mounts when using a callbackRef", async () => { + const controller = createController(); + + // Mounting later. Previously this wouldn't have been picked up + // automatically, and users would've had to wait for the mount, and only + // then set the ref from null, to its actual object value. + // @see https://github.com/ZeeCoder/use-resize-observer/issues/43#issuecomment-674719609 + const Test = ({ mount = false }) => { + const { callbackRef, width, height } = useResizeObserver< + HTMLDivElement + >(); + + controller.reportMeasuredSize({ width, height }); + + if (!mount) { + return null; + } + + return
; + }; + + // Reported size should be undefined before the hook kicks in + const { rerender } = render(); + controller.assertMeasuredSize({ width: undefined, height: undefined }); + + // Once the hook supposedly kicked in, it should still be undefined, as the ref is not in use yet. + await awaitNextFrame(); + controller.assertMeasuredSize({ width: undefined, height: undefined }); + + // Once mounted, the hook should automatically pick the new element up with + // the callback ref. + rerender(); + await awaitNextFrame(); + controller.assertMeasuredSize({ width: 100, height: 200 }); + }); + + it("should work with a callback ref on delayed mount", async () => { + const controller = createController(); + const Test = () => { + const { callbackRef, width, height } = useResizeObserver< + HTMLDivElement + >(); + + controller.reportMeasuredSize({ width, height }); + + return
; + }; + + render(); + controller.assertMeasuredSize({ width: undefined, height: undefined }); + + await awaitNextFrame(); + controller.assertMeasuredSize({ width: 100, height: 200 }); + }); + + it("should work with a regular element as the 'custom ref' too", async () => { + const controller = createController(); + const Test = () => { + // This is a bit of a roundabout way of simulating the case where we have + // an Element from somewhere, when we can't simply use a callback ref. + const [element, setElement] = useState(null); + const { width, height } = useResizeObserver({ + ref: element, + }); + + // Interestingly, if this callback is not memoised, then on each render, + // the callback is called with "null", then again with the element. + const receiveElement = useCallback((element: HTMLDivElement) => { + setElement(element); + }, []); + + controller.reportMeasuredSize({ width, height }); + + return
; + }; + + render(); + controller.assertMeasuredSize({ width: undefined, height: undefined }); + + await awaitNextFrame(); + controller.assertMeasuredSize({ width: 100, height: 200 }); + }); }); diff --git a/tests/utils/awaitNextFrame.ts b/tests/utils/awaitNextFrame.ts new file mode 100644 index 0000000..e4bd3e5 --- /dev/null +++ b/tests/utils/awaitNextFrame.ts @@ -0,0 +1,3 @@ +export default function awaitNextFrame() { + return new Promise((resolve) => setTimeout(resolve, 1000 / 60)); +} diff --git a/tests/utils/createController.ts b/tests/utils/createController.ts new file mode 100644 index 0000000..616dd68 --- /dev/null +++ b/tests/utils/createController.ts @@ -0,0 +1,70 @@ +// Creates a shared object which can be used in the test function, as well as +// within the test component. +// Provides some often used functions as well. +import { RefObject } from "react"; +import awaitNextFrame from "./awaitNextFrame"; + +const setSizePlaceholder: SetSizeFunction = async (params: SizeParams) => {}; + +type SizeParams = { + width?: number; + height?: number; +}; +type SetSizeFunction = (params: SizeParams) => Promise; +const doSetSize = async ( + ref: RefObject, + params: SizeParams +) => { + if (!ref.current) { + return; + } + + if (params.width) { + ref.current.style.width = `${params.width}px`; + } + if (params.height) { + ref.current.style.height = `${params.height}px`; + } + + // Returning a promise here to wait for the next "tick". + // Useful when you want to check the effects of a size change + await awaitNextFrame(); +}; + +export default function createController() { + let renderCount = 0; + const incrementRenderCount = () => renderCount++; + const assertRenderCount = (count: number) => expect(renderCount).toBe(count); + + let measuredWidth: number | undefined; + let measuredHeight: number | undefined; + const reportMeasuredSize = (params: SizeParams) => { + if (typeof params.width === "number") { + measuredWidth = params.width; + } + if (typeof params.height === "number") { + measuredHeight = params.height; + } + }; + const assertMeasuredSize = (params: SizeParams) => { + expect(measuredWidth).toBe(params.width); + expect(measuredHeight).toBe(params.height); + }; + + const controller = { + incrementRenderCount, + assertRenderCount, + reportMeasuredSize, + assertMeasuredSize, + setSize: setSizePlaceholder, + provideSetSizeFunction: (ref: RefObject) => {}, // Placeholder to make TS happy + triggerRender: async () => {}, + }; + + // surely there's a better way to do this? + controller.provideSetSizeFunction = (ref: RefObject) => { + controller.setSize = (params: SizeParams) => doSetSize(ref, params); + }; + + return controller; +} diff --git a/tests/utils/useRenderTrigger.ts b/tests/utils/useRenderTrigger.ts new file mode 100644 index 0000000..ad9f9d4 --- /dev/null +++ b/tests/utils/useRenderTrigger.ts @@ -0,0 +1,11 @@ +import { useCallback, useState } from "react"; +import awaitNextFrame from "./awaitNextFrame"; + +export default function useRenderTrigger() { + const [, setTrigger] = useState(false); + + return useCallback(async () => { + setTrigger((val) => !val); + await awaitNextFrame(); + }, []); +} diff --git a/travis/getNpmDistTag.js b/travis/getNpmDistTag.js new file mode 100644 index 0000000..f04ce4a --- /dev/null +++ b/travis/getNpmDistTag.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +const version = require("../package.json").version; + +// Extracting the dist tag, assuming that it would look something like this: +// `1.2.3-alpha.1` +const matches = version.match(/^[^-]*-([^.]+).*$/); + +// "latest" is default. +// See: https://docs.npmjs.com/adding-dist-tags-to-packages +const distTag = matches ? matches[1] : "latest"; + +process.stdout.write(distTag);