-
Notifications
You must be signed in to change notification settings - Fork 27
feat: add useMutationObserver #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c9df89c
76b18d0
b85ad85
cfa52dc
49e51ec
e9af5ca
06c9e9f
c2b92d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| import { | ||
| DependencyList, | ||
| EffectCallback, | ||
| useRef, | ||
| useEffect, | ||
| useDebugValue, | ||
| } from 'react' | ||
| import useWillUnmount from './useWillUnmount' | ||
| import useMounted from './useMounted' | ||
|
|
||
| export type EffectHook = (effect: EffectCallback, deps?: DependencyList) => void | ||
|
|
||
| export type IsEqual<TDeps extends DependencyList> = ( | ||
| nextDeps: TDeps, | ||
| prevDeps: TDeps, | ||
| ) => boolean | ||
|
|
||
| export type CustomEffectOptions<TDeps extends DependencyList> = { | ||
| isEqual: IsEqual<TDeps> | ||
| effectHook?: EffectHook | ||
| } | ||
|
|
||
| type CleanUp = { | ||
| (): void | ||
| cleanup?: ReturnType<EffectCallback> | ||
| } | ||
|
|
||
| /** | ||
| * a useEffect() hook with customized depedency comparision | ||
| * | ||
| * @param effect The effect callback | ||
| * @param dependencies A list of dependencies | ||
| * @param isEqual A function comparing the next and previous dependencyLists | ||
| */ | ||
| function useCustomEffect<TDeps extends DependencyList = DependencyList>( | ||
| effect: EffectCallback, | ||
| dependencies: TDeps, | ||
| isEqual: IsEqual<TDeps>, | ||
| ): void | ||
| /** | ||
| * a useEffect() hook with customized depedency comparision | ||
| * | ||
| * @param effect The effect callback | ||
| * @param dependencies A list of dependencies | ||
| * @param options | ||
| * @param options.isEqual A function comparing the next and previous dependencyLists | ||
| * @param options.effectHook the underlying effect hook used, defaults to useEffect | ||
| */ | ||
| function useCustomEffect<TDeps extends DependencyList = DependencyList>( | ||
| effect: EffectCallback, | ||
| dependencies: TDeps, | ||
| options: CustomEffectOptions<TDeps>, | ||
| ): void | ||
| function useCustomEffect<TDeps extends DependencyList = DependencyList>( | ||
| effect: EffectCallback, | ||
| dependencies: TDeps, | ||
| isEqualOrOptions: IsEqual<TDeps> | CustomEffectOptions<TDeps>, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure why the overloads are needed at all... seems like anything that would pass validation for the overloads would pass validation here?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Overloads here are mostly for documentation not safety, yes this would be fine by itself but i think the intellisense is usually nicer to read with overloads |
||
| ) { | ||
| const isMounted = useMounted() | ||
| const { isEqual, effectHook = useEffect } = | ||
| typeof isEqualOrOptions === 'function' | ||
| ? { isEqual: isEqualOrOptions } | ||
| : isEqualOrOptions | ||
|
|
||
| const dependenciesRef = useRef<TDeps>() | ||
| dependenciesRef.current = dependencies | ||
|
|
||
| const cleanupRef = useRef<CleanUp | null>(null) | ||
|
|
||
| effectHook(() => { | ||
| // If the ref the is `null` it's either the first effect or the last effect | ||
| // ran and was cleared, meaning _this_ update should run, b/c the equality | ||
| // check failed on in the cleanup of the last effect. | ||
| if (cleanupRef.current === null) { | ||
| const cleanup = effect() | ||
|
|
||
| cleanupRef.current = () => { | ||
| if (isMounted() && isEqual(dependenciesRef.current!, dependencies)) { | ||
| return | ||
| } | ||
|
|
||
| cleanupRef.current = null | ||
| if (cleanup) cleanup() | ||
| } | ||
| } | ||
|
|
||
| return cleanupRef.current | ||
| }) | ||
|
|
||
| useDebugValue(effect) | ||
jquense marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| export default useCustomEffect | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import useCustomEffect from './useCustomEffect' | ||
| import isEqual from 'lodash/isEqual' | ||
| import useImmediateUpdateEffect from './useImmediateUpdateEffect' | ||
| import useEventCallback from './useEventCallback' | ||
|
|
||
| type Deps = [Element | null | undefined, MutationObserverInit] | ||
|
|
||
| function isDepsEqual( | ||
| [nextElement, nextConfig]: Deps, | ||
| [prevElement, prevConfig]: Deps, | ||
| ) { | ||
| return nextElement === prevElement && isEqual(nextConfig, prevConfig) | ||
| } | ||
|
|
||
| /** | ||
| * Observe mutations on a DOM node or tree of DOM nodes. | ||
| * Depends on the `MutationObserver` api. | ||
| * | ||
| * ```ts | ||
| * const [element, attachRef] = useCallbackRef(null); | ||
| * | ||
| * useMutationObserver(element, { subtree: true }, (records) => { | ||
| * | ||
| * }); | ||
| * | ||
| * return ( | ||
| * <div ref={attachRef} /> | ||
| * ) | ||
| * ``` | ||
| * | ||
| * @param element The DOM element to observe | ||
| * @param config The observer configuration | ||
| * @param callback A callback fired when a mutation occurs | ||
| */ | ||
| function useMutationObserver( | ||
| element: Element | null | undefined, | ||
| config: MutationObserverInit, | ||
| callback: MutationCallback, | ||
| ): void { | ||
| const fn = useEventCallback(callback) | ||
|
|
||
| useCustomEffect( | ||
| () => { | ||
| if (!element) return | ||
|
|
||
| // The behavior around reusing mutation observers is confusing | ||
| // observing again _should_ disable the last listener but doesn't | ||
| // seem to always be the case, maybe just in JSDOM? In any case the cost | ||
| // to redeclaring it is gonna be fairly low anyway, so make it simple | ||
| const observer = new MutationObserver(fn) | ||
|
|
||
| observer.observe(element, config) | ||
|
|
||
| return () => { | ||
| observer.disconnect() | ||
| } | ||
| }, | ||
| [element, config], | ||
| { | ||
| isEqual: isDepsEqual, | ||
| // Intentionally done in render, otherwise observer will miss any | ||
| // changes made to the DOM during this update | ||
| effectHook: useImmediateUpdateEffect, | ||
jquense marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, | ||
| ) | ||
| } | ||
|
|
||
| export default useMutationObserver | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import useCustomEffect from '../src/useCustomEffect' | ||
| import useImmediateUpdateEffect from '../src/useImmediateUpdateEffect' | ||
| import { renderHook } from './helpers' | ||
|
|
||
| describe('useCustomEffect', () => { | ||
| it('should run custom isEqual logic', () => { | ||
| const teardown = jest.fn() | ||
|
|
||
| const spy = jest.fn().mockImplementation(() => teardown) | ||
| const isEqual = jest.fn((next, prev) => next[0].foo === prev[0].foo) | ||
|
|
||
| const [, wrapper] = renderHook( | ||
| ({ value }) => { | ||
| useCustomEffect(spy, [value], isEqual) | ||
| }, | ||
| { value: { foo: true } }, | ||
| ) | ||
|
|
||
| expect(spy).toHaveBeenCalledTimes(1) | ||
|
|
||
| // matches isEqual | ||
| wrapper.setProps({ value: { foo: true } }) | ||
|
|
||
| expect(spy).toHaveBeenCalledTimes(1) | ||
|
|
||
| // update that should trigger | ||
| wrapper.setProps({ value: { foo: false } }) | ||
|
|
||
| expect(spy).toHaveBeenCalledTimes(2) | ||
| expect(isEqual).toHaveBeenCalledTimes(2) | ||
|
|
||
| expect(teardown).toBeCalledTimes(1) | ||
| expect(spy).toHaveBeenCalledTimes(2) | ||
|
|
||
| wrapper.unmount() | ||
| expect(teardown).toBeCalledTimes(2) | ||
| }) | ||
|
|
||
| it('should accept different hooks', () => { | ||
| const spy = jest.fn() | ||
| const hookSpy = jest.fn().mockImplementation(useImmediateUpdateEffect) | ||
|
|
||
| renderHook( | ||
| ({ value }) => { | ||
| useCustomEffect(spy, [value], { | ||
| isEqual: (next, prev) => next[0].foo === prev[0].foo, | ||
| effectHook: hookSpy, | ||
| }) | ||
| }, | ||
| { value: { foo: true } }, | ||
| ) | ||
|
|
||
| // the update and unmount hook setup | ||
| expect(hookSpy).toHaveBeenCalledTimes(1) | ||
| // not called b/c useImmediateUpdateEffect doesn't run on initial render | ||
| expect(spy).toHaveBeenCalledTimes(0) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this typing seems odd to me.
DependencyListis typed asReadonlyArray<any>... but realistically we're going to have something like a tuple type. do tuple types extend read-only arrays of type any?... apparently so, but it's weird