From dc174c624100dfd3352c5d332fdea9a6b088bf37 Mon Sep 17 00:00:00 2001 From: Cody Bennett Date: Sat, 10 Aug 2024 05:11:46 -0500 Subject: [PATCH] fix(native): drop use-measure for react-dom peerdep --- example/src/demos/SVGRenderer.tsx | 6 +- packages/fiber/package.json | 3 +- packages/fiber/src/web/Canvas.tsx | 3 +- packages/fiber/src/web/use-measure.ts | 194 ++++++++++++++++++++++++++ yarn.lock | 12 +- 5 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 packages/fiber/src/web/use-measure.ts diff --git a/example/src/demos/SVGRenderer.tsx b/example/src/demos/SVGRenderer.tsx index 928391fec8..dc632ab11e 100644 --- a/example/src/demos/SVGRenderer.tsx +++ b/example/src/demos/SVGRenderer.tsx @@ -9,8 +9,7 @@ import { events, ReconcilerRoot, } from '@react-three/fiber' -import useMeasure, { Options as ResizeOptions } from 'react-use-measure' -import mergeRefs from 'react-merge-refs' +import { useMeasure, Options as ResizeOptions } from '../../../packages/fiber/src/web/use-measure' import { SVGRenderer } from 'three-stdlib' function TorusKnot() { @@ -47,6 +46,7 @@ function Canvas({ children, resize, style, className, ...props }: Props) { const [bind, size] = useMeasure({ scroll: true, debounce: { scroll: 50, resize: 0 }, ...resize }) const ref = React.useRef(null!) + React.useImperativeHandle(bind, () => ref.current, []) const [gl] = useState(() => new SVGRenderer() as unknown as THREE.WebGLRenderer) const root = React.useRef>(null!) @@ -67,7 +67,7 @@ function Canvas({ children, resize, style, className, ...props }: Props) { return (
diff --git a/packages/fiber/package.json b/packages/fiber/package.json index c5fa2686d5..d576933423 100644 --- a/packages/fiber/package.json +++ b/packages/fiber/package.json @@ -43,13 +43,14 @@ }, "dependencies": { "@babel/runtime": "^7.17.8", + "@types/debounce": "^1.2.1", "@types/react-reconciler": "^0.26.7", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", + "debounce": "^1.2.1", "its-fine": "^1.0.6", "react-reconciler": "^0.27.0", - "react-use-measure": "^2.1.1", "scheduler": "^0.21.0", "suspend-react": "^0.1.3", "zustand": "^3.7.1" diff --git a/packages/fiber/src/web/Canvas.tsx b/packages/fiber/src/web/Canvas.tsx index a25584e357..6384921275 100644 --- a/packages/fiber/src/web/Canvas.tsx +++ b/packages/fiber/src/web/Canvas.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import * as THREE from 'three' -import useMeasure from 'react-use-measure' -import type { Options as ResizeOptions } from 'react-use-measure' +import { useMeasure, Options as ResizeOptions } from './use-measure' import { useContextBridge, FiberProvider } from 'its-fine' import { isRef, SetBlock, Block, ErrorBoundary, useMutableCallback, useIsomorphicLayoutEffect } from '../core/utils' import { ReconcilerRoot, extend, createRoot, unmountComponentAtNode, RenderProps } from '../core' diff --git a/packages/fiber/src/web/use-measure.ts b/packages/fiber/src/web/use-measure.ts new file mode 100644 index 0000000000..84e01815ef --- /dev/null +++ b/packages/fiber/src/web/use-measure.ts @@ -0,0 +1,194 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { useEffect, useState, useRef, useMemo } from 'react' +import createDebounce from 'debounce' + +declare type ResizeObserverCallback = (entries: any[], observer: ResizeObserver) => void +declare class ResizeObserver { + constructor(callback: ResizeObserverCallback) + observe(target: Element, options?: any): void + unobserve(target: Element): void + disconnect(): void + static toString(): string +} + +export interface RectReadOnly { + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly top: number + readonly right: number + readonly bottom: number + readonly left: number + [key: string]: number +} + +type HTMLOrSVGElement = HTMLElement | SVGElement + +type Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly, () => void] + +type State = { + element: HTMLOrSVGElement | null + scrollContainers: HTMLOrSVGElement[] | null + resizeObserver: ResizeObserver | null + lastBounds: RectReadOnly +} + +export type Options = { + debounce?: number | { scroll: number; resize: number } + scroll?: boolean + polyfill?: { new (cb: ResizeObserverCallback): ResizeObserver } + offsetSize?: boolean +} + +export function useMeasure( + { debounce, scroll, polyfill, offsetSize }: Options = { debounce: 0, scroll: false, offsetSize: false }, +): Result { + const ResizeObserver = polyfill || (typeof window !== 'undefined' && (window as any).ResizeObserver) + + const [bounds, set] = useState({ + left: 0, + top: 0, + width: 0, + height: 0, + bottom: 0, + right: 0, + x: 0, + y: 0, + }) + + // In test mode + if (!ResizeObserver) { + // @ts-ignore + bounds.width = 1280 + // @ts-ignore + bounds.height = 800 + return [() => {}, bounds, () => {}] + } + + // keep all state in a ref + const state = useRef({ element: null, scrollContainers: null, resizeObserver: null, lastBounds: bounds }) + + // set actual debounce values early, so effects know if they should react accordingly + const scrollDebounce = debounce ? (typeof debounce === 'number' ? debounce : debounce.scroll) : null + const resizeDebounce = debounce ? (typeof debounce === 'number' ? debounce : debounce.resize) : null + + // make sure to update state only as long as the component is truly mounted + const mounted = useRef(false) + useEffect(() => { + mounted.current = true + return () => void (mounted.current = false) + }) + + // memoize handlers, so event-listeners know when they should update + const [forceRefresh, resizeChange, scrollChange] = useMemo(() => { + const callback = () => { + if (!state.current.element) return + const { left, top, width, height, bottom, right, x, y } = + state.current.element.getBoundingClientRect() as unknown as RectReadOnly + + const size = { + left, + top, + width, + height, + bottom, + right, + x, + y, + } + + if (state.current.element instanceof HTMLElement && offsetSize) { + size.height = state.current.element.offsetHeight + size.width = state.current.element.offsetWidth + } + + Object.freeze(size) + if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) set((state.current.lastBounds = size)) + } + return [ + callback, + resizeDebounce ? createDebounce(callback, resizeDebounce) : callback, + scrollDebounce ? createDebounce(callback, scrollDebounce) : callback, + ] + }, [set, offsetSize, scrollDebounce, resizeDebounce]) + + // cleanup current scroll-listeners / observers + function removeListeners() { + if (state.current.scrollContainers) { + state.current.scrollContainers.forEach((element) => element.removeEventListener('scroll', scrollChange, true)) + state.current.scrollContainers = null + } + + if (state.current.resizeObserver) { + state.current.resizeObserver.disconnect() + state.current.resizeObserver = null + } + } + + // add scroll-listeners / observers + function addListeners() { + if (!state.current.element) return + state.current.resizeObserver = new ResizeObserver(scrollChange) + state.current.resizeObserver!.observe(state.current.element) + if (scroll && state.current.scrollContainers) { + state.current.scrollContainers.forEach((scrollContainer) => + scrollContainer.addEventListener('scroll', scrollChange, { capture: true, passive: true }), + ) + } + } + + // the ref we expose to the user + const ref = (node: HTMLOrSVGElement | null) => { + if (!node || node === state.current.element) return + removeListeners() + state.current.element = node + state.current.scrollContainers = findScrollContainers(node) + addListeners() + } + + // add general event listeners + useOnWindowScroll(scrollChange, Boolean(scroll)) + useOnWindowResize(resizeChange) + + // respond to changes that are relevant for the listeners + useEffect(() => { + removeListeners() + addListeners() + }, [scroll, scrollChange, resizeChange]) + + // remove all listeners when the components unmounts + useEffect(() => removeListeners, []) + return [ref, bounds, forceRefresh] +} + +// Adds native resize listener to window +function useOnWindowResize(onWindowResize: (event: Event) => void) { + useEffect(() => { + const cb = onWindowResize + window.addEventListener('resize', cb) + return () => void window.removeEventListener('resize', cb) + }, [onWindowResize]) +} +function useOnWindowScroll(onScroll: () => void, enabled: boolean) { + useEffect(() => { + if (enabled) { + const cb = onScroll + window.addEventListener('scroll', cb, { capture: true, passive: true }) + return () => void window.removeEventListener('scroll', cb, true) + } + }, [onScroll, enabled]) +} + +// Returns a list of scroll offsets +function findScrollContainers(element: HTMLOrSVGElement | null): HTMLOrSVGElement[] { + const result: HTMLOrSVGElement[] = [] + if (!element || element === document.body) return result + const { overflow, overflowX, overflowY } = window.getComputedStyle(element) + if ([overflow, overflowX, overflowY].some((prop) => prop === 'auto' || prop === 'scroll')) result.push(element) + return [...result, ...findScrollContainers(element.parentElement)] +} + +// Checks if element boundaries are equal +const keys: (keyof RectReadOnly)[] = ['x', 'y', 'top', 'bottom', 'left', 'right', 'width', 'height'] +const areBoundsEqual = (a: RectReadOnly, b: RectReadOnly): boolean => keys.every((key) => a[key] === b[key]) diff --git a/yarn.lock b/yarn.lock index 78c25d47de..56c9a1abf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2815,6 +2815,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/debounce@^1.2.1": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.4.tgz#cb7e85d9ad5ababfac2f27183e8ac8b576b2abb3" + integrity sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw== + "@types/draco3d@^1.4.0": version "1.4.2" resolved "https://registry.yarnpkg.com/@types/draco3d/-/draco3d-1.4.2.tgz#7faccb809db2a5e19b9efb97c5f2eb9d64d527ea" @@ -8911,13 +8916,6 @@ react-test-renderer@^18.0.0: react-shallow-renderer "^16.13.1" scheduler "^0.21.0" -react-use-measure@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/react-use-measure/-/react-use-measure-2.1.1.tgz#5824537f4ee01c9469c45d5f7a8446177c6cc4ba" - integrity sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig== - dependencies: - debounce "^1.2.1" - react-use-refs@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/react-use-refs/-/react-use-refs-1.0.1.tgz#44cab5f4764b3fa4a112189c0058fc8752d1eb2c"