diff --git a/packages/eui/changelogs/upcoming/9511.md b/packages/eui/changelogs/upcoming/9511.md new file mode 100644 index 000000000000..9d1762939c2e --- /dev/null +++ b/packages/eui/changelogs/upcoming/9511.md @@ -0,0 +1 @@ +- Replaced `EuiObserver` abstract base class with a `useObserver` hook diff --git a/packages/eui/src/components/observer/mutation_observer/mutation_observer.tsx b/packages/eui/src/components/observer/mutation_observer/mutation_observer.tsx index dba3fe9b166a..748fb0a461ab 100644 --- a/packages/eui/src/components/observer/mutation_observer/mutation_observer.tsx +++ b/packages/eui/src/components/observer/mutation_observer/mutation_observer.tsx @@ -6,9 +6,15 @@ * Side Public License, v 1. */ -import { ReactNode, useEffect } from 'react'; +import { + ReactNode, + useCallback, + useEffect, + useRef, + FunctionComponent, +} from 'react'; -import { EuiObserver } from '../observer'; +import { useObserver } from '../observer'; export interface EuiMutationObserverProps { /** @@ -19,24 +25,37 @@ export interface EuiMutationObserverProps { observerOptions?: MutationObserverInit; } -export class EuiMutationObserver extends EuiObserver { - name = 'EuiMutationObserver'; +export const EuiMutationObserver: FunctionComponent< + EuiMutationObserverProps +> = ({ children, onMutation, observerOptions }) => { + // Store onMutation and observerOptions in refs so the observer callback + // and setup always use the latest prop values without needing to + // re-subscribe (which would cause the ref callback to cycle) + const onMutationRef = useRef(onMutation); + onMutationRef.current = onMutation; - // the `onMutation` prop may change while the observer is bound, abstracting - // it out into a separate function means the current `onMutation` value is used - onMutation: MutationCallback = (records, observer) => { - this.props.onMutation(records, observer); - }; + const observerOptionsRef = useRef(observerOptions); + observerOptionsRef.current = observerOptions; - beginObserve = () => { - const childNode = this.childNode!; - this.observer = makeMutationObserver( - childNode, - this.props.observerOptions, - this.onMutation - ); - }; -} + const mutationCallback: MutationCallback = useCallback( + (records, observer) => { + onMutationRef.current(records, observer); + }, + [] + ); + + const beginObserve = useCallback( + (node: Element) => + makeMutationObserver(node, observerOptionsRef.current, mutationCallback), + [mutationCallback] + ); + + const updateChildNode = useObserver(beginObserve, 'EuiMutationObserver'); + + return children(updateChildNode); +}; + +EuiMutationObserver.displayName = 'EuiMutationObserver'; const makeMutationObserver = ( node: Element, diff --git a/packages/eui/src/components/observer/observer.ts b/packages/eui/src/components/observer/observer.ts index 66a11a912237..e5c96c35a3d3 100644 --- a/packages/eui/src/components/observer/observer.ts +++ b/packages/eui/src/components/observer/observer.ts @@ -6,59 +6,68 @@ * Side Public License, v 1. */ -import { Component, ReactNode } from 'react'; - -interface BaseProps { - /** - * ReactNode to render as this component's content - */ - children: (ref: any) => ReactNode; -} +import { useCallback, useEffect, useRef } from 'react'; export interface Observer { disconnect: () => void; observe: (element: Element, options?: { [key: string]: any }) => void; } -export class EuiObserver extends Component { - protected name: string = 'EuiObserver'; - protected childNode: null | Element = null; - protected observer: null | Observer = null; +/** + * A shared custom hook that provides a pattern for observing DOM nodes + * via browser observer APIs. Used by `EuiResizeObserver` and `EuiMutationObserver`. + * + * @param beginObserve - A callback that receives the target DOM element and should + * create and return the observer instance (e.g., `ResizeObserver`). + * @param componentName - Optional name used in error messages when no ref is + * attached on mount, mirroring the guard previously in `EuiObserver`. + */ +export const useObserver = ( + beginObserve: (node: Element) => Observer | undefined, + componentName: string = 'useObserver' +) => { + const childNodeRef = useRef(null); + const observerRef = useRef(null); - componentDidMount() { - if (this.childNode == null) { - throw new Error(`${this.name} did not receive a ref`); - } - } + // Store beginObserve in a ref so the ref callback doesn't cycle + const beginObserveRef = useRef(beginObserve); + beginObserveRef.current = beginObserve; - componentWillUnmount() { - if (this.observer != null) { - this.observer.disconnect(); + // Store componentName in a ref so the mount-only effect can access the + // latest value without needing it as a dependency. + const componentNameRef = useRef(componentName); + componentNameRef.current = componentName; + + // Guard: throw if the ref callback was never called (no element attached), + // mirroring the check previously in EuiObserver.componentDidMount. + // Also cleans up the observer on unmount. + // Empty deps: run only on mount/unmount — componentName is only used for the + // error message and changing it must not disconnect/re-connect the observer. + useEffect(() => { + if (childNodeRef.current == null) { + throw new Error(`${componentNameRef.current} did not receive a ref`); } - } + return () => { + observerRef.current?.disconnect(); + }; + }, []); - updateChildNode = (ref: Element) => { - if (this.childNode === ref) return; // node hasn't changed + const updateChildNode = useCallback((ref: Element | null) => { + if (childNodeRef.current === ref) return; // node hasn't changed // if there's an existing observer disconnect it - if (this.observer != null) { - this.observer.disconnect(); - this.observer = null; + if (observerRef.current != null) { + observerRef.current.disconnect(); + observerRef.current = null; } - this.childNode = ref; + childNodeRef.current = ref; - if (this.childNode != null) { - this.beginObserve(); + if (childNodeRef.current != null) { + observerRef.current = + beginObserveRef.current(childNodeRef.current) ?? null; } - }; - - beginObserve: () => void = () => { - throw new Error('EuiObserver has no default observation method'); - }; + }, []); - render() { - const props: BaseProps = this.props; - return props.children(this.updateChildNode); - } -} + return updateChildNode; +}; diff --git a/packages/eui/src/components/observer/resize_observer/resize_observer.tsx b/packages/eui/src/components/observer/resize_observer/resize_observer.tsx index 99af074afd41..c2344395c4bb 100644 --- a/packages/eui/src/components/observer/resize_observer/resize_observer.tsx +++ b/packages/eui/src/components/observer/resize_observer/resize_observer.tsx @@ -6,8 +6,15 @@ * Side Public License, v 1. */ -import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; -import { EuiObserver } from '../observer'; +import { + ReactNode, + useCallback, + useEffect, + useRef, + useState, + FunctionComponent, +} from 'react'; +import { useObserver } from '../observer'; export interface EuiResizeObserverProps { /** @@ -20,36 +27,38 @@ export interface EuiResizeObserverProps { export const hasResizeObserver = typeof window !== 'undefined' && typeof window.ResizeObserver !== 'undefined'; -export class EuiResizeObserver extends EuiObserver { - name = 'EuiResizeObserver'; +export const EuiResizeObserver: FunctionComponent = ({ + children, + onResize, +}) => { + const onResizeRef = useRef(onResize); + onResizeRef.current = onResize; - state = { - height: 0, - width: 0, - }; + const sizeRef = useRef({ height: 0, width: 0 }); - onResize: ResizeObserverCallback = ([entry]) => { + const resizeCallback: ResizeObserverCallback = useCallback(([entry]) => { const { inlineSize: width, blockSize: height } = entry.borderBoxSize[0]; // Check for actual resize event - if (this.state.height === height && this.state.width === width) { + if (sizeRef.current.height === height && sizeRef.current.width === width) { return; } - this.props.onResize({ - height, - width, - }); - this.setState({ height, width }); - }; - - beginObserve = () => { - // The superclass checks that childNode is not null before invoking - // beginObserve() - const childNode = this.childNode!; - this.observer = makeResizeObserver(childNode, this.onResize)!; - }; -} + sizeRef.current = { height, width }; + onResizeRef.current({ height, width }); + }, []); + + const beginObserve = useCallback( + (node: Element) => makeResizeObserver(node, resizeCallback), + [resizeCallback] + ); + + const updateChildNode = useObserver(beginObserve, 'EuiResizeObserver'); + + return children(updateChildNode); +}; + +EuiResizeObserver.displayName = 'EuiResizeObserver'; const makeResizeObserver = ( node: Element, diff --git a/packages/eui/src/components/popover/popover.test.tsx b/packages/eui/src/components/popover/popover.test.tsx index a39256a6e6b5..eeb73c49415f 100644 --- a/packages/eui/src/components/popover/popover.test.tsx +++ b/packages/eui/src/components/popover/popover.test.tsx @@ -508,7 +508,7 @@ describe('EuiPopover', () => { expect(activeAnimationFrames.size).toEqual(1); unmount(); - expect(window.clearTimeout).toHaveBeenCalledTimes(9); + expect(window.clearTimeout).toHaveBeenCalledTimes(8); expect(cafSpy).toHaveBeenCalledTimes(1); expect(activeAnimationFrames.size).toEqual(0);