Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
"modules": false
}
]
],
"plugins": [
[
"babel-plugin-transform-rename-import",
{
"original": "lodash",
"replacement": "lodash-es"
}
]
]
},
"test": {
Expand Down
44 changes: 24 additions & 20 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,33 +46,37 @@
"react": "^16.8.0"
},
"devDependencies": {
"@4c/babel-preset": "^7.2.1",
"@4c/cli": "^1.0.3",
"@4c/jest-preset": "^1.4.3",
"@4c/rollout": "^2.0.3",
"@4c/tsconfig": "^0.3.0",
"@babel/cli": "^7.7.0",
"@babel/core": "^7.7.2",
"@4c/babel-preset": "^7.3.3",
"@4c/cli": "^2.0.1",
"@4c/jest-preset": "^1.5.0",
"@4c/rollout": "^2.1.2",
"@4c/tsconfig": "^0.3.1",
"@babel/cli": "^7.8.4",
"@babel/core": "^7.8.7",
"@babel/preset-typescript": "^7.7.2",
"@types/enzyme": "^3.10.3",
"@types/jest": "^24.0.23",
"@types/react": "^16.9.12",
"babel-jest": "^24.9.0",
"@types/enzyme": "^3.10.5",
"@types/jest": "^25.1.4",
"@types/lodash": "^4.14.149",
"@types/react": "^16.9.23",
"babel-jest": "^25.1.0",
"babel-plugin-transform-rename-import": "^2.3.0",
"cherry-pick": "^0.5.0",
"codecov": "^3.6.5",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.15.1",
"eslint": "^6.7.0",
"husky": "^3.1.0",
"jest": "^24.9.0",
"lint-staged": "^9.4.3",
"husky": "^4.2.3",
"jest": "^25.1.0",
"lint-staged": "^10.0.8",
"mq-polyfill": "^1.1.8",
"prettier": "^1.19.1",
"react": "^16.9.0",
"react-dom": "^16.12.0",
"rimraf": "^3.0.0",
"typescript": "^3.7.2"
"react": "^16.13.0",
"react-dom": "^16.13.0",
"rimraf": "^3.0.2",
"typescript": "^3.8.3"
},
"readme": "ERROR: No README data found!",
"_id": "@restart/hooks@0.3.20"
"dependencies": {
"lodash": "^4.17.15",
"lodash-es": "^4.17.15"
}
}
93 changes: 93 additions & 0 deletions src/useCustomEffect.ts
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>(
Copy link
Member

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. DependencyList is typed as ReadonlyArray<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

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>,
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

Choose a reason for hiding this comment

The 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)
}

export default useCustomEffect
16 changes: 13 additions & 3 deletions src/useImmediateUpdateEffect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DependencyList, useRef } from 'react'
import { DependencyList, useRef, EffectCallback } from 'react'
import useStableMemo from './useStableMemo'
import useWillUnmount from './useWillUnmount'

/**
* An _immediate_ effect that runs an effect callback when its dependency array
Expand All @@ -17,16 +18,25 @@ import useStableMemo from './useStableMemo'
*
* @category effects
*/
function useImmediateUpdateEffect(effect: () => void, deps: DependencyList) {
function useImmediateUpdateEffect(
effect: EffectCallback,
deps: DependencyList,
) {
const firstRef = useRef(true)
const tearDown = useRef<ReturnType<EffectCallback>>()

useWillUnmount(() => {
if (tearDown.current) tearDown.current()
})

useStableMemo(() => {
if (firstRef.current) {
firstRef.current = false
return
}

effect()
if (tearDown.current) tearDown.current()
tearDown.current = effect()
}, deps)
}

Expand Down
68 changes: 68 additions & 0 deletions src/useMutationObserver.ts
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,
},
)
}

export default useMutationObserver
2 changes: 1 addition & 1 deletion src/useStableMemo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DependencyList, useEffect, useRef } from 'react'
import { DependencyList, useRef } from 'react'

function isEqual(a: DependencyList, b: DependencyList) {
if (a.length !== b.length) return false
Expand Down
58 changes: 58 additions & 0 deletions test/useCustomEffect.test.tsx
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)
})
})
13 changes: 12 additions & 1 deletion test/useImmediateUpdateEffect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { renderHook } from './helpers'

describe('useImmediateUpdateEffect', () => {
it('should run update after value changes', () => {
const spy = jest.fn()
const teardown = jest.fn()
const spy = jest.fn().mockImplementation(() => teardown)

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

expect(spy).toHaveBeenCalledTimes(1)

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

expect(spy).toHaveBeenCalledTimes(1)

// second update
wrapper.setProps({ value: 4, other: true })

expect(teardown).toBeCalledTimes(1)
expect(spy).toHaveBeenCalledTimes(2)

wrapper.unmount()
expect(teardown).toBeCalledTimes(2)
})
})
Loading