Skip to content

Commit 75cd372

Browse files
authored
Merge pull request #24 from react-restart/mitation
feat: add useMutationObserver
2 parents ed37bf3 + c2b92d1 commit 75cd372

10 files changed

+1740
-1144
lines changed

.babelrc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
"modules": false
1010
}
1111
]
12+
],
13+
"plugins": [
14+
[
15+
"babel-plugin-transform-rename-import",
16+
{
17+
"original": "lodash",
18+
"replacement": "lodash-es"
19+
}
20+
]
1221
]
1322
},
1423
"test": {

package.json

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,33 +46,37 @@
4646
"react": "^16.8.0"
4747
},
4848
"devDependencies": {
49-
"@4c/babel-preset": "^7.2.1",
50-
"@4c/cli": "^1.0.3",
51-
"@4c/jest-preset": "^1.4.3",
52-
"@4c/rollout": "^2.0.3",
53-
"@4c/tsconfig": "^0.3.0",
54-
"@babel/cli": "^7.7.0",
55-
"@babel/core": "^7.7.2",
49+
"@4c/babel-preset": "^7.3.3",
50+
"@4c/cli": "^2.0.1",
51+
"@4c/jest-preset": "^1.5.0",
52+
"@4c/rollout": "^2.1.2",
53+
"@4c/tsconfig": "^0.3.1",
54+
"@babel/cli": "^7.8.4",
55+
"@babel/core": "^7.8.7",
5656
"@babel/preset-typescript": "^7.7.2",
57-
"@types/enzyme": "^3.10.3",
58-
"@types/jest": "^24.0.23",
59-
"@types/react": "^16.9.12",
60-
"babel-jest": "^24.9.0",
57+
"@types/enzyme": "^3.10.5",
58+
"@types/jest": "^25.1.4",
59+
"@types/lodash": "^4.14.149",
60+
"@types/react": "^16.9.23",
61+
"babel-jest": "^25.1.0",
62+
"babel-plugin-transform-rename-import": "^2.3.0",
6163
"cherry-pick": "^0.5.0",
6264
"codecov": "^3.6.5",
6365
"enzyme": "^3.10.0",
6466
"enzyme-adapter-react-16": "^1.15.1",
6567
"eslint": "^6.7.0",
66-
"husky": "^3.1.0",
67-
"jest": "^24.9.0",
68-
"lint-staged": "^9.4.3",
68+
"husky": "^4.2.3",
69+
"jest": "^25.1.0",
70+
"lint-staged": "^10.0.8",
6971
"mq-polyfill": "^1.1.8",
7072
"prettier": "^1.19.1",
71-
"react": "^16.9.0",
72-
"react-dom": "^16.12.0",
73-
"rimraf": "^3.0.0",
74-
"typescript": "^3.7.2"
73+
"react": "^16.13.0",
74+
"react-dom": "^16.13.0",
75+
"rimraf": "^3.0.2",
76+
"typescript": "^3.8.3"
7577
},
76-
"readme": "ERROR: No README data found!",
77-
"_id": "@restart/[email protected]"
78+
"dependencies": {
79+
"lodash": "^4.17.15",
80+
"lodash-es": "^4.17.15"
81+
}
7882
}

src/useCustomEffect.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
DependencyList,
3+
EffectCallback,
4+
useRef,
5+
useEffect,
6+
useDebugValue,
7+
} from 'react'
8+
import useWillUnmount from './useWillUnmount'
9+
import useMounted from './useMounted'
10+
11+
export type EffectHook = (effect: EffectCallback, deps?: DependencyList) => void
12+
13+
export type IsEqual<TDeps extends DependencyList> = (
14+
nextDeps: TDeps,
15+
prevDeps: TDeps,
16+
) => boolean
17+
18+
export type CustomEffectOptions<TDeps extends DependencyList> = {
19+
isEqual: IsEqual<TDeps>
20+
effectHook?: EffectHook
21+
}
22+
23+
type CleanUp = {
24+
(): void
25+
cleanup?: ReturnType<EffectCallback>
26+
}
27+
28+
/**
29+
* a useEffect() hook with customized depedency comparision
30+
*
31+
* @param effect The effect callback
32+
* @param dependencies A list of dependencies
33+
* @param isEqual A function comparing the next and previous dependencyLists
34+
*/
35+
function useCustomEffect<TDeps extends DependencyList = DependencyList>(
36+
effect: EffectCallback,
37+
dependencies: TDeps,
38+
isEqual: IsEqual<TDeps>,
39+
): void
40+
/**
41+
* a useEffect() hook with customized depedency comparision
42+
*
43+
* @param effect The effect callback
44+
* @param dependencies A list of dependencies
45+
* @param options
46+
* @param options.isEqual A function comparing the next and previous dependencyLists
47+
* @param options.effectHook the underlying effect hook used, defaults to useEffect
48+
*/
49+
function useCustomEffect<TDeps extends DependencyList = DependencyList>(
50+
effect: EffectCallback,
51+
dependencies: TDeps,
52+
options: CustomEffectOptions<TDeps>,
53+
): void
54+
function useCustomEffect<TDeps extends DependencyList = DependencyList>(
55+
effect: EffectCallback,
56+
dependencies: TDeps,
57+
isEqualOrOptions: IsEqual<TDeps> | CustomEffectOptions<TDeps>,
58+
) {
59+
const isMounted = useMounted()
60+
const { isEqual, effectHook = useEffect } =
61+
typeof isEqualOrOptions === 'function'
62+
? { isEqual: isEqualOrOptions }
63+
: isEqualOrOptions
64+
65+
const dependenciesRef = useRef<TDeps>()
66+
dependenciesRef.current = dependencies
67+
68+
const cleanupRef = useRef<CleanUp | null>(null)
69+
70+
effectHook(() => {
71+
// If the ref the is `null` it's either the first effect or the last effect
72+
// ran and was cleared, meaning _this_ update should run, b/c the equality
73+
// check failed on in the cleanup of the last effect.
74+
if (cleanupRef.current === null) {
75+
const cleanup = effect()
76+
77+
cleanupRef.current = () => {
78+
if (isMounted() && isEqual(dependenciesRef.current!, dependencies)) {
79+
return
80+
}
81+
82+
cleanupRef.current = null
83+
if (cleanup) cleanup()
84+
}
85+
}
86+
87+
return cleanupRef.current
88+
})
89+
90+
useDebugValue(effect)
91+
}
92+
93+
export default useCustomEffect

src/useImmediateUpdateEffect.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { DependencyList, useRef } from 'react'
1+
import { DependencyList, useRef, EffectCallback } from 'react'
22
import useStableMemo from './useStableMemo'
3+
import useWillUnmount from './useWillUnmount'
34

45
/**
56
* An _immediate_ effect that runs an effect callback when its dependency array
@@ -17,16 +18,25 @@ import useStableMemo from './useStableMemo'
1718
*
1819
* @category effects
1920
*/
20-
function useImmediateUpdateEffect(effect: () => void, deps: DependencyList) {
21+
function useImmediateUpdateEffect(
22+
effect: EffectCallback,
23+
deps: DependencyList,
24+
) {
2125
const firstRef = useRef(true)
26+
const tearDown = useRef<ReturnType<EffectCallback>>()
27+
28+
useWillUnmount(() => {
29+
if (tearDown.current) tearDown.current()
30+
})
2231

2332
useStableMemo(() => {
2433
if (firstRef.current) {
2534
firstRef.current = false
2635
return
2736
}
2837

29-
effect()
38+
if (tearDown.current) tearDown.current()
39+
tearDown.current = effect()
3040
}, deps)
3141
}
3242

src/useMutationObserver.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import useCustomEffect from './useCustomEffect'
2+
import isEqual from 'lodash/isEqual'
3+
import useImmediateUpdateEffect from './useImmediateUpdateEffect'
4+
import useEventCallback from './useEventCallback'
5+
6+
type Deps = [Element | null | undefined, MutationObserverInit]
7+
8+
function isDepsEqual(
9+
[nextElement, nextConfig]: Deps,
10+
[prevElement, prevConfig]: Deps,
11+
) {
12+
return nextElement === prevElement && isEqual(nextConfig, prevConfig)
13+
}
14+
15+
/**
16+
* Observe mutations on a DOM node or tree of DOM nodes.
17+
* Depends on the `MutationObserver` api.
18+
*
19+
* ```ts
20+
* const [element, attachRef] = useCallbackRef(null);
21+
*
22+
* useMutationObserver(element, { subtree: true }, (records) => {
23+
*
24+
* });
25+
*
26+
* return (
27+
* <div ref={attachRef} />
28+
* )
29+
* ```
30+
*
31+
* @param element The DOM element to observe
32+
* @param config The observer configuration
33+
* @param callback A callback fired when a mutation occurs
34+
*/
35+
function useMutationObserver(
36+
element: Element | null | undefined,
37+
config: MutationObserverInit,
38+
callback: MutationCallback,
39+
): void {
40+
const fn = useEventCallback(callback)
41+
42+
useCustomEffect(
43+
() => {
44+
if (!element) return
45+
46+
// The behavior around reusing mutation observers is confusing
47+
// observing again _should_ disable the last listener but doesn't
48+
// seem to always be the case, maybe just in JSDOM? In any case the cost
49+
// to redeclaring it is gonna be fairly low anyway, so make it simple
50+
const observer = new MutationObserver(fn)
51+
52+
observer.observe(element, config)
53+
54+
return () => {
55+
observer.disconnect()
56+
}
57+
},
58+
[element, config],
59+
{
60+
isEqual: isDepsEqual,
61+
// Intentionally done in render, otherwise observer will miss any
62+
// changes made to the DOM during this update
63+
effectHook: useImmediateUpdateEffect,
64+
},
65+
)
66+
}
67+
68+
export default useMutationObserver

src/useStableMemo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DependencyList, useEffect, useRef } from 'react'
1+
import { DependencyList, useRef } from 'react'
22

33
function isEqual(a: DependencyList, b: DependencyList) {
44
if (a.length !== b.length) return false

test/useCustomEffect.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import useCustomEffect from '../src/useCustomEffect'
2+
import useImmediateUpdateEffect from '../src/useImmediateUpdateEffect'
3+
import { renderHook } from './helpers'
4+
5+
describe('useCustomEffect', () => {
6+
it('should run custom isEqual logic', () => {
7+
const teardown = jest.fn()
8+
9+
const spy = jest.fn().mockImplementation(() => teardown)
10+
const isEqual = jest.fn((next, prev) => next[0].foo === prev[0].foo)
11+
12+
const [, wrapper] = renderHook(
13+
({ value }) => {
14+
useCustomEffect(spy, [value], isEqual)
15+
},
16+
{ value: { foo: true } },
17+
)
18+
19+
expect(spy).toHaveBeenCalledTimes(1)
20+
21+
// matches isEqual
22+
wrapper.setProps({ value: { foo: true } })
23+
24+
expect(spy).toHaveBeenCalledTimes(1)
25+
26+
// update that should trigger
27+
wrapper.setProps({ value: { foo: false } })
28+
29+
expect(spy).toHaveBeenCalledTimes(2)
30+
expect(isEqual).toHaveBeenCalledTimes(2)
31+
32+
expect(teardown).toBeCalledTimes(1)
33+
expect(spy).toHaveBeenCalledTimes(2)
34+
35+
wrapper.unmount()
36+
expect(teardown).toBeCalledTimes(2)
37+
})
38+
39+
it('should accept different hooks', () => {
40+
const spy = jest.fn()
41+
const hookSpy = jest.fn().mockImplementation(useImmediateUpdateEffect)
42+
43+
renderHook(
44+
({ value }) => {
45+
useCustomEffect(spy, [value], {
46+
isEqual: (next, prev) => next[0].foo === prev[0].foo,
47+
effectHook: hookSpy,
48+
})
49+
},
50+
{ value: { foo: true } },
51+
)
52+
53+
// the update and unmount hook setup
54+
expect(hookSpy).toHaveBeenCalledTimes(1)
55+
// not called b/c useImmediateUpdateEffect doesn't run on initial render
56+
expect(spy).toHaveBeenCalledTimes(0)
57+
})
58+
})

test/useImmediateUpdateEffect.test.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { renderHook } from './helpers'
33

44
describe('useImmediateUpdateEffect', () => {
55
it('should run update after value changes', () => {
6-
const spy = jest.fn()
6+
const teardown = jest.fn()
7+
const spy = jest.fn().mockImplementation(() => teardown)
78

89
const [, wrapper] = renderHook(
910
({ value }) => {
@@ -18,8 +19,18 @@ describe('useImmediateUpdateEffect', () => {
1819

1920
expect(spy).toHaveBeenCalledTimes(1)
2021

22+
// update that doesn't change the deps Array
2123
wrapper.setProps({ value: 2, other: true })
2224

2325
expect(spy).toHaveBeenCalledTimes(1)
26+
27+
// second update
28+
wrapper.setProps({ value: 4, other: true })
29+
30+
expect(teardown).toBeCalledTimes(1)
31+
expect(spy).toHaveBeenCalledTimes(2)
32+
33+
wrapper.unmount()
34+
expect(teardown).toBeCalledTimes(2)
2435
})
2536
})

0 commit comments

Comments
 (0)