diff --git a/docs/api/hooks.md b/docs/api/hooks.md index 0c0e5bfe8..b436e58cd 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -290,6 +290,209 @@ export const CounterComponent = ({ value }) => { } ``` +## `useTrackedState()` + +### How does this get used? + +`useTrackedState` allows components to read values from the Redux store state. +It is similar to `useSelector`, but uses an internal tracking system +to detect which state values are read in a component, +without needing to define a selector function. + +> **Note**: It doesn't mean to replace `useSelector` completely. It gives a new way of connecting Redux store to React. + +The usage of `useTrackedState` is like the following. + +```jsx +import React from 'react' +import { useTrackedState } from 'react-redux' + +export const CounterComponent = () => { + const { counter } = useTrackedState() + return
{counter}
+} +``` + +If it needs to use props, it can be done so. + +```jsx +import React from 'react' +import { useTrackedState } from 'react-redux' + +export const TodoListItem = props => { + const state = useTrackedState() + const todo = state.todos[props.id] + return
{todo.text}
+} +``` + +### Why would you want to use it? + +#### For beginners + +When learning Redux for the first time, +it would be good to learn things step by step. +`useTrackedState` allows developers to directly access the store state. +They can learn selectors later for separation of concerns. + +#### For intermediates + +`useSelector` requires a selector to produce a stable value for performance. +For example, + +```js +const selectUser = state => ({ + name: state.user.name, + friends: state.user.friends.map(({ name }) => name), +}) +const user = useSelector(selectUser) +``` + +such a selector needs to be memoized to avoid extra re-renders. + +`useTrackedState` doesn't require memoized selectors. + +```js +const state = useTrackedState() +const user = selectUser(state) +``` + +This works fine without extra re-renders. + +Even a custom hook can be created for this purpose. + +```js +const useTrackedSelector = selector => selector(useTrackedState()) +``` + +This can be used instead of `useSelector` for some cases. + +#### For experts + +`useTrackedState` doesn't have [the technical issue](#stale-props-and-zombie-children) that `useSelector` has. +This is because `useTrackedState` doesn't run selectors in checkForUpdates. + +### What are the differences in behavior compared to useSelector? + +#### Capabilities + +A selector can create a derived values. For example: + +```js +const isYoung = state => state.person.age < 11; +``` + +This selector computes a boolean value. + +```js +const young = useSelector(isYoung); +``` + +With useSelector, a component only re-renders when the result of `isYoung` is changed. + +```js +const young = useTrackedState().person.age < 11; +``` + +Whereas with useTrackedState, a component re-renders whenever the `age` value is changed. + +#### How to debug + +Unlike useSelector, useTrackedState's behavior may seem like a magic. +Disclosing the tracked information stored in useTrackedState could mitigate it. +While useSelector shows the selected state with useDebugValue, +useTrackedState shows the tracked state paths with useDebugValue. + +By using React Developer Tools, you can investigate the tracked +information in the hook. It is inside `AffectedDebugValue`. +If you experience extra re-renders or missing re-renders, +you can check the tracked state paths which may help finding bugs +in your application code or possible bugs in the library code. + +#### Caveats + +Proxy-based tracking has limitations. + +- Proxied states are referentially equal only in per-hook basis + +```js +const state1 = useTrackedState(); +const state2 = useTrackedState(); +// state1 and state2 is not referentially equal +// even if the underlying redux state is referentially equal. +``` + +You should use `useTrackedState` only once in a component. + +- An object referential change doesn't trigger re-render if an property of the object is accessed in previous render + +```js +const state = useTrackedState(); +const { foo } = state; +return ; + +const Child = React.memo(({ foo }) => { + // ... +}; +// if foo doesn't change, Child won't render, so foo.id is only marked as used. +// it won't trigger Child to re-render even if foo is changed. +``` + +It's recommended to use primitive values for props with memo'd components. + +- Proxied state might behave unexpectedly outside render + +Proxies are basically transparent, and it should behave like normal objects. +However, there can be edge cases where it behaves unexpectedly. +For example, if you console.log a proxied value, +it will display a proxy wrapping an object. +Notice, it will be kept tracking outside render, +so any prorerty access will mark as used to trigger re-render on updates. + +useTrackedState will unwrap a Proxy before wrapping with a new Proxy, +hence, it will work fine in usual use cases. +There's only one known pitfall: If you wrap proxied state with your own Proxy +outside the control of useTrackedState, +it might lead memory leaks, because useTrackedState +wouldn't know how to unwrap your own Proxy. + +To work around such edge cases, use primitive values. + +```js +const state = useTrackedState(); +const dispatch = useUpdate(); +dispatch({ type: 'FOO', value: state.fooObj }); // Instead of using objects, +dispatch({ type: 'FOO', value: state.fooStr }); // Use primitives. +``` + +#### Performance + +useSelector is sometimes more performant because Proxies are overhead. + +useTrackedState is sometimes more performant because it doesn't need to invoke a selector when checking for updates. + +### What are the limitations in browser support? + +Proxies are not supported in old browsers like IE11. + +However, one could use [proxy-polyfill](https://github.com/GoogleChrome/proxy-polyfill) with care. + +There are some limitations with the polyfill. Most notably, it will fail to track undefined properties. + +```js +const state = { count: 0 } + +// this works with polyfill. +state.count + +// this won't work with polyfill. +state.foo +``` + +So, if the state shape is defined initially and never changed, it should be fine. + +`Object.key()` and `in` operater is not supported. There might be other cases that polyfill doesn't support. + ## Custom context The `` component allows you to specify an alternate context via the `context` prop. This is useful if you're building a complex reusable component, and you don't want your store to collide with any Redux store your consumers' applications might use. diff --git a/src/hooks/useTrackedState.js b/src/hooks/useTrackedState.js new file mode 100644 index 000000000..ff7284ee2 --- /dev/null +++ b/src/hooks/useTrackedState.js @@ -0,0 +1,128 @@ +/* eslint-env es6 */ + +import { + useReducer, + useRef, + useMemo, + useContext, + useEffect, + useDebugValue +} from 'react' +import { useReduxContext as useDefaultReduxContext } from './useReduxContext' +import Subscription from '../utils/Subscription' +import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' +import { ReactReduxContext } from '../components/Context' +import { createDeepProxy, isDeepChanged } from '../utils/deepProxy' + +// convert "affected" (WeakMap) to serializable value (array of array of string) +const affectedToPathList = (state, affected) => { + const list = [] + const walk = (obj, path) => { + const used = affected.get(obj) + if (used) { + used.forEach(key => { + walk(obj[key], path ? [...path, key] : [key]) + }) + } else if (path) { + list.push(path) + } + } + walk(state) + return list +} + +const useAffectedDebugValue = (state, affected) => { + const pathList = useRef(null) + useEffect(() => { + pathList.current = affectedToPathList(state, affected) + }) + useDebugValue(pathList.current) +} + +function useTrackedStateWithStoreAndSubscription(store, contextSub) { + const [, forceRender] = useReducer(s => s + 1, 0) + + const subscription = useMemo(() => new Subscription(store, contextSub), [ + store, + contextSub + ]) + + const state = store.getState() + const affected = new WeakMap() + const latestTracked = useRef(null) + useIsomorphicLayoutEffect(() => { + latestTracked.current = { + state, + affected, + cache: new WeakMap() + } + }) + useIsomorphicLayoutEffect(() => { + function checkForUpdates() { + const nextState = store.getState() + if ( + latestTracked.current.state !== nextState && + isDeepChanged( + latestTracked.current.state, + nextState, + latestTracked.current.affected, + latestTracked.current.cache + ) + ) { + forceRender() + } + } + + subscription.onStateChange = checkForUpdates + subscription.trySubscribe() + + checkForUpdates() + + return () => subscription.tryUnsubscribe() + }, [store, subscription]) + + if (process.env.NODE_ENV !== 'production') { + useAffectedDebugValue(state, affected) + } + + const proxyCache = useRef(new WeakMap()) // per-hook proxyCache + return createDeepProxy(state, affected, proxyCache.current) +} + +/** + * Hook factory, which creates a `useTrackedState` hook bound to a given context. + * + * @param {React.Context} [context=ReactReduxContext] Context passed to your ``. + * @returns {Function} A `useTrackedState` hook bound to the specified context. + */ +export function createTrackedStateHook(context = ReactReduxContext) { + const useReduxContext = + context === ReactReduxContext + ? useDefaultReduxContext + : () => useContext(context) + return function useTrackedState() { + const { store, subscription: contextSub } = useReduxContext() + + return useTrackedStateWithStoreAndSubscription(store, contextSub) + } +} + +/** + * A hook to return the redux store's state. + * + * This hook tracks the state usage and only triggers + * re-rerenders if the used part of the state is changed. + * + * @returns {any} the whole state + * + * @example + * + * import React from 'react' + * import { useTrackedState } from 'react-redux' + * + * export const CounterComponent = () => { + * const state = useTrackedState() + * return
{state.counter}
+ * } + */ +export const useTrackedState = /*#__PURE__*/ createTrackedStateHook() diff --git a/src/index.js b/src/index.js index d02c35a07..34204cb15 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,10 @@ import connect from './connect/connect' import { useDispatch, createDispatchHook } from './hooks/useDispatch' import { useSelector, createSelectorHook } from './hooks/useSelector' import { useStore, createStoreHook } from './hooks/useStore' +import { + useTrackedState, + createTrackedStateHook +} from './hooks/useTrackedState' import { setBatch } from './utils/batch' import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates' @@ -25,5 +29,7 @@ export { createSelectorHook, useStore, createStoreHook, + useTrackedState, + createTrackedStateHook, shallowEqual } diff --git a/src/utils/deepProxy.js b/src/utils/deepProxy.js new file mode 100644 index 000000000..e5f42e50a --- /dev/null +++ b/src/utils/deepProxy.js @@ -0,0 +1,151 @@ +/* eslint-env es6 */ + +// deep proxy for useTrackedState + +const OWN_KEYS_SYMBOL = Symbol('OWN_KEYS') +const TRACK_MEMO_SYMBOL = Symbol('TRACK_MEMO') +const GET_ORIGINAL_SYMBOL = Symbol('GET_ORIGINAL') + +// check if obj is a plain object or an array +const isPlainObject = obj => { + try { + const proto = Object.getPrototypeOf(obj) + return proto === Object.prototype || proto === Array.prototype + } catch (e) { + return false + } +} + +// copy obj if frozen +const unfreeze = obj => { + if (!Object.isFrozen(obj)) return obj + if (Array.isArray(obj)) { + return Array.from(obj) + } + return Object.assign({}, obj) +} + +const createProxyHandler = () => ({ + recordUsage(key) { + if (this.trackObj) return + let used = this.affected.get(this.originalObj) + if (!used) { + used = new Set() + this.affected.set(this.originalObj, used) + } + used.add(key) + }, + recordObjectAsUsed() { + this.trackObj = true + this.affected.delete(this.originalObj) + }, + get(target, key) { + if (key === GET_ORIGINAL_SYMBOL) { + return this.originalObj + } + this.recordUsage(key) + return createDeepProxy(target[key], this.affected, this.proxyCache) + }, + has(target, key) { + if (key === TRACK_MEMO_SYMBOL) { + this.recordObjectAsUsed() + return true + } + // LIMITATION: + // We simply record the same as get. + // This means { a: {} } and { a: {} } is detected as changed, + // if 'a' in obj is handled. + this.recordUsage(key) + return key in target + }, + ownKeys(target) { + this.recordUsage(OWN_KEYS_SYMBOL) + return Reflect.ownKeys(target) + } +}) + +export const createDeepProxy = (obj, affected, proxyCache) => { + if (!isPlainObject(obj)) return obj + const origObj = obj[GET_ORIGINAL_SYMBOL] // unwrap proxy + if (origObj) obj = origObj + let proxyHandler = proxyCache && proxyCache.get(obj) + if (!proxyHandler) { + proxyHandler = createProxyHandler() + proxyHandler.proxy = new Proxy(unfreeze(obj), proxyHandler) + proxyHandler.originalObj = obj + proxyHandler.trackObj = false // for trackMemo + if (proxyCache) { + proxyCache.set(obj, proxyHandler) + } + } + proxyHandler.affected = affected + proxyHandler.proxyCache = proxyCache + return proxyHandler.proxy +} + +const isOwnKeysChanged = (origObj, nextObj) => { + const origKeys = Reflect.ownKeys(origObj) + const nextKeys = Reflect.ownKeys(nextObj) + return ( + origKeys.length !== nextKeys.length || + origKeys.some((k, i) => k !== nextKeys[i]) + ) +} + +export const isDeepChanged = ( + origObj, + nextObj, + affected, + cache, + assumeChangedIfNotAffected +) => { + if (origObj === nextObj) return false + if (typeof origObj !== 'object' || origObj === null) return true + if (typeof nextObj !== 'object' || nextObj === null) return true + const used = affected.get(origObj) + if (!used) return !!assumeChangedIfNotAffected + if (cache) { + const hit = cache.get(origObj) + if (hit && hit.nextObj === nextObj) { + return hit.changed + } + // for object with cycles (changed is `undefined`) + cache.set(origObj, { nextObj }) + } + let changed = null + for (const key of used) { + const c = + key === OWN_KEYS_SYMBOL + ? isOwnKeysChanged(origObj, nextObj) + : isDeepChanged( + origObj[key], + nextObj[key], + affected, + cache, + assumeChangedIfNotAffected !== false + ) + if (typeof c === 'boolean') changed = c + if (changed) break + } + if (changed === null) changed = !!assumeChangedIfNotAffected + if (cache) { + cache.set(origObj, { nextObj, changed }) + } + return changed +} + +// explicitly track object with memo +export const trackMemo = obj => { + if (isPlainObject(obj)) { + return TRACK_MEMO_SYMBOL in obj + } + return false +} + +// get original object from proxy +export const getUntrackedObject = obj => { + if (isPlainObject(obj)) { + return obj[GET_ORIGINAL_SYMBOL] || null + } + return null +} diff --git a/test/hooks/useTrackedState.spec.js b/test/hooks/useTrackedState.spec.js new file mode 100644 index 000000000..5ce2b1f50 --- /dev/null +++ b/test/hooks/useTrackedState.spec.js @@ -0,0 +1,265 @@ +/*eslint-disable react/prop-types*/ + +import React from 'react' +import { createStore } from 'redux' +import { renderHook, act } from '@testing-library/react-hooks' +import * as rtl from '@testing-library/react' +import { + Provider as ProviderMock, + useTrackedState, + createTrackedStateHook +} from '../../src/index.js' +import { useReduxContext } from '../../src/hooks/useReduxContext' + +describe('React', () => { + describe('hooks', () => { + describe('useTrackedState', () => { + let store + let renderedItems = [] + + beforeEach(() => { + store = createStore(({ count } = { count: -1 }) => ({ + count: count + 1 + })) + renderedItems = [] + }) + + afterEach(() => rtl.cleanup()) + + describe('core subscription behavior', () => { + it('selects the state on initial render', () => { + const { result } = renderHook(() => useTrackedState().count, { + wrapper: props => + }) + + expect(result.current).toEqual(0) + }) + + it('selects the state and renders the component when the store updates', () => { + const { result } = renderHook(() => useTrackedState().count, { + wrapper: props => + }) + + expect(result.current).toEqual(0) + + act(() => { + store.dispatch({ type: '' }) + }) + + expect(result.current).toEqual(1) + }) + }) + + describe('lifeycle interactions', () => { + it('always uses the latest state', () => { + store = createStore(c => c + 1, -1) + + const Comp = () => { + const value = useTrackedState() + 1 + renderedItems.push(value) + return
+ } + + rtl.render( + + + + ) + + expect(renderedItems).toEqual([1]) + + store.dispatch({ type: '' }) + + expect(renderedItems).toEqual([1, 2]) + }) + + it('subscribes to the store synchronously', () => { + let rootSubscription + + const Parent = () => { + const { subscription } = useReduxContext() + rootSubscription = subscription + const count = useTrackedState().count + return count === 1 ? : null + } + + const Child = () => { + const count = useTrackedState().count + return
{count}
+ } + + rtl.render( + + + + ) + + expect(rootSubscription.listeners.get().length).toBe(1) + + store.dispatch({ type: '' }) + + expect(rootSubscription.listeners.get().length).toBe(2) + }) + + it('unsubscribes when the component is unmounted', () => { + let rootSubscription + + const Parent = () => { + const { subscription } = useReduxContext() + rootSubscription = subscription + const count = useTrackedState().count + return count === 0 ? : null + } + + const Child = () => { + const count = useTrackedState().count + return
{count}
+ } + + rtl.render( + + + + ) + + expect(rootSubscription.listeners.get().length).toBe(2) + + store.dispatch({ type: '' }) + + expect(rootSubscription.listeners.get().length).toBe(1) + }) + + it('notices store updates between render and store subscription effect', () => { + const Comp = () => { + const count = useTrackedState().count + renderedItems.push(count) + + // I don't know a better way to trigger a store update before the + // store subscription effect happens + if (count === 0) { + store.dispatch({ type: '' }) + } + + return
{count}
+ } + + rtl.render( + + + + ) + + expect(renderedItems).toEqual([0, 1]) + }) + }) + + describe('performance optimizations and bail-outs', () => { + it('defaults to ref-equality to prevent unnecessary updates', () => { + const state = {} + store = createStore(() => ({ obj: state })) + + const Comp = () => { + const value = useTrackedState().obj + renderedItems.push(value) + return
+ } + + rtl.render( + + + + ) + + expect(renderedItems.length).toBe(1) + + store.dispatch({ type: '' }) + + expect(renderedItems.length).toBe(1) + }) + }) + + describe('tracked cases', () => { + it('only re-render used prop is changed', () => { + store = createStore( + ({ count1, count2 } = { count1: -1, count2: 9 }) => ({ + count1: count1 + 1, + count2: count2 + }) + ) + + const Comp1 = () => { + const value = useTrackedState().count1 + renderedItems.push(value) + return
+ } + + const Comp2 = () => { + const value = useTrackedState().count2 + renderedItems.push(value) + return
+ } + + rtl.render( + + + + + ) + + expect(renderedItems).toEqual([0, 9]) + + store.dispatch({ type: '' }) + + expect(renderedItems).toEqual([0, 9, 1]) + }) + }) + }) + + describe('createTrackedStateHook', () => { + let defaultStore + let customStore + + beforeEach(() => { + defaultStore = createStore(({ count } = { count: -1 }) => ({ + count: count + 1 + })) + customStore = createStore(({ count } = { count: 10 }) => ({ + count: count + 2 + })) + }) + + afterEach(() => rtl.cleanup()) + + it('subscribes to the correct store', () => { + const nestedContext = React.createContext(null) + const useCustomTrackedState = createTrackedStateHook(nestedContext) + let defaultCount = null + let customCount = null + + const DisplayDefaultCount = ({ children = null }) => { + const count = useTrackedState().count + defaultCount = count + return <>{children} + } + const DisplayCustomCount = ({ children = null }) => { + const count = useCustomTrackedState().count + customCount = count + return <>{children} + } + + rtl.render( + + + + + + + + ) + + expect(defaultCount).toBe(0) + expect(customCount).toBe(12) + }) + }) + }) +}) diff --git a/test/utils/deepProxy.spec.js b/test/utils/deepProxy.spec.js new file mode 100644 index 000000000..404e807ff --- /dev/null +++ b/test/utils/deepProxy.spec.js @@ -0,0 +1,375 @@ +/* eslint-env es6 */ + +import { + createDeepProxy, + isDeepChanged, + trackMemo, + getUntrackedObject +} from '../../src/utils/deepProxy' + +const noop = () => undefined + +describe('shallow object spec', () => { + it('no property access', () => { + const s1 = { a: 'a', b: 'b' } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + noop(p1) + expect(isDeepChanged(s1, { a: 'a', b: 'b' }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: 'a2', b: 'b' }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: 'a', b: 'b2' }, a1)).toBe(false) + }) + + it('one property access', () => { + const s1 = { a: 'a', b: 'b' } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + noop(p1.a) + expect(isDeepChanged(s1, { a: 'a', b: 'b' }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: 'a2', b: 'b' }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: 'a', b: 'b2' }, a1)).toBe(false) + }) +}) + +describe('deep object spec', () => { + it('intermediate property access', () => { + const s1 = { a: { b: 'b', c: 'c' } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + noop(p1.a) + expect(isDeepChanged(s1, { a: s1.a }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 'b2', c: 'c' } }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: { b: 'b', c: 'c2' } }, a1)).toBe(true) + }) + + it('leaf property access', () => { + const s1 = { a: { b: 'b', c: 'c' } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + noop(p1.a.b) + expect(isDeepChanged(s1, { a: s1.a }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 'b2', c: 'c' } }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: { b: 'b', c: 'c2' } }, a1)).toBe(false) + }) +}) + +describe('reference equality spec', () => { + it('simple', () => { + const proxyCache = new WeakMap() + const s1 = { a: 'a', b: 'b' } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a) + const s2 = s1 // keep the reference + const a2 = new WeakMap() + const p2 = createDeepProxy(s2, a2, proxyCache) + noop(p2.b) + expect(p1).toBe(p2) + expect(isDeepChanged(s1, { a: 'a', b: 'b' }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: 'a2', b: 'b' }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: 'a', b: 'b2' }, a1)).toBe(false) + expect(isDeepChanged(s2, { a: 'a', b: 'b' }, a2)).toBe(false) + expect(isDeepChanged(s2, { a: 'a2', b: 'b' }, a2)).toBe(false) + expect(isDeepChanged(s2, { a: 'a', b: 'b2' }, a2)).toBe(true) + }) + + it('nested', () => { + const proxyCache = new WeakMap() + const s1 = { a: { b: 'b', c: 'c' } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.b) + const s2 = { a: s1.a } // keep the reference + const a2 = new WeakMap() + const p2 = createDeepProxy(s2, a2, proxyCache) + noop(p2.a.c) + expect(p1).not.toBe(p2) + expect(p1.a).toBe(p2.a) + expect(isDeepChanged(s1, { a: { b: 'b', c: 'c' } }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 'b2', c: 'c' } }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: { b: 'b', c: 'c2' } }, a1)).toBe(false) + expect(isDeepChanged(s2, { a: { b: 'b', c: 'c' } }, a2)).toBe(false) + expect(isDeepChanged(s2, { a: { b: 'b2', c: 'c' } }, a2)).toBe(false) + expect(isDeepChanged(s2, { a: { b: 'b', c: 'c2' } }, a2)).toBe(true) + }) +}) + +describe('array spec', () => { + it('length', () => { + const s1 = [1, 2, 3] + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + noop(p1.length) + expect(isDeepChanged(s1, [1, 2, 3], a1)).toBe(false) + expect(isDeepChanged(s1, [1, 2, 3, 4], a1)).toBe(true) + expect(isDeepChanged(s1, [1, 2], a1)).toBe(true) + expect(isDeepChanged(s1, [1, 2, 4], a1)).toBe(false) + }) + + it('forEach', () => { + const s1 = [1, 2, 3] + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + p1.forEach(noop) + expect(isDeepChanged(s1, [1, 2, 3], a1)).toBe(false) + expect(isDeepChanged(s1, [1, 2, 3, 4], a1)).toBe(true) + expect(isDeepChanged(s1, [1, 2], a1)).toBe(true) + expect(isDeepChanged(s1, [1, 2, 4], a1)).toBe(true) + }) + + it('for-of', () => { + const s1 = [1, 2, 3] + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + // eslint-disable-next-line no-restricted-syntax + for (const x of p1) { + noop(x) + } + expect(isDeepChanged(s1, [1, 2, 3], a1)).toBe(false) + expect(isDeepChanged(s1, [1, 2, 3, 4], a1)).toBe(true) + expect(isDeepChanged(s1, [1, 2], a1)).toBe(true) + expect(isDeepChanged(s1, [1, 2, 4], a1)).toBe(true) + }) +}) + +describe('keys spec', () => { + it('object keys', () => { + const s1 = { a: { b: 'b' }, c: 'c' } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + noop(Object.keys(p1)) + expect(isDeepChanged(s1, { a: s1.a, c: 'c' }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 'b' }, c: 'c' }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: s1.a }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: s1.a, c: 'c', d: 'd' }, a1)).toBe(true) + }) + + it('for-in', () => { + const s1 = { a: { b: 'b' }, c: 'c' } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const k in p1) { + noop(k) + } + expect(isDeepChanged(s1, { a: s1.a, c: 'c' }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 'b' }, c: 'c' }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: s1.a }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: s1.a, c: 'c', d: 'd' }, a1)).toBe(true) + }) + + it('single in operator', () => { + const s1 = { a: { b: 'b' }, c: 'c' } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1) + noop('a' in p1) + expect(isDeepChanged(s1, { a: s1.a, c: 'c' }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: s1.a }, a1)).toBe(false) + expect(isDeepChanged(s1, { c: 'c', d: 'd' }, a1)).toBe(true) + }) +}) + +describe('special objects spec', () => { + it('object with cycles', () => { + const proxyCache = new WeakMap() + const s1 = { a: 'a' } + s1.self = s1 + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + const c1 = new WeakMap() + noop(p1.self.a) + expect(isDeepChanged(s1, s1, a1, c1)).toBe(false) + expect(isDeepChanged(s1, { a: 'a', self: s1 }, a1, c1)).toBe(false) + const s2 = { a: 'a' } + s2.self = s2 + expect(isDeepChanged(s1, s2, a1, c1)).toBe(false) + const s3 = { a: 'a2' } + s3.self = s3 + expect(isDeepChanged(s1, s3, a1, c1)).toBe(true) + }) + + it('object with cycles 2', () => { + const proxyCache = new WeakMap() + const s1 = { a: { b: 'b' } } + s1.self = s1 + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + const c1 = new WeakMap() + noop(p1.self.a) + expect(isDeepChanged(s1, s1, a1, c1)).toBe(false) + expect(isDeepChanged(s1, { a: s1.a, self: s1 }, a1, c1)).toBe(false) + const s2 = { a: { b: 'b' } } + s2.self = s2 + expect(isDeepChanged(s1, s2, a1, c1)).toBe(true) + }) + + it('frozen object', () => { + const proxyCache = new WeakMap() + const s1 = { a: { b: 'b' } } + Object.freeze(s1) + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.b) + expect(isDeepChanged(s1, s1, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 'b' } }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 'b2' } }, a1)).toBe(true) + }) +}) + +describe('builtin objects spec', () => { + // we can't track builtin objects + + it('boolean', () => { + /* eslint-disable no-new-wrappers */ + const proxyCache = new WeakMap() + const s1 = { a: new Boolean(false) } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.valueOf()) + expect(isDeepChanged(s1, s1, a1)).toBe(false) + expect(isDeepChanged(s1, { a: new Boolean(false) }, a1)).toBe(true) + /* eslint-enable no-new-wrappers */ + }) + + it('error', () => { + const proxyCache = new WeakMap() + const s1 = { a: new Error('e') } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.message) + expect(isDeepChanged(s1, s1, a1)).toBe(false) + expect(isDeepChanged(s1, { a: new Error('e') }, a1)).toBe(true) + }) + + it('date', () => { + const proxyCache = new WeakMap() + const s1 = { a: new Date('2019-05-11T12:22:29.293Z') } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.getTime()) + expect(isDeepChanged(s1, s1, a1)).toBe(false) + expect( + isDeepChanged(s1, { a: new Date('2019-05-11T12:22:29.293Z') }, a1) + ).toBe(true) + }) + + it('regexp', () => { + const proxyCache = new WeakMap() + const s1 = { a: /a/ } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.test('a')) + expect(isDeepChanged(s1, s1, a1)).toBe(false) + expect(isDeepChanged(s1, { a: /a/ }, a1)).toBe(true) + }) + + it('map', () => { + const proxyCache = new WeakMap() + const s1 = { a: new Map() } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.entries()) + expect(isDeepChanged(s1, s1, a1)).toBe(false) + expect(isDeepChanged(s1, { a: new Map() }, a1)).toBe(true) + }) + + it('typed array', () => { + const proxyCache = new WeakMap() + const s1 = { a: Int8Array.from([1]) } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a[0]) + expect(isDeepChanged(s1, s1, a1)).toBe(false) + expect(isDeepChanged(s1, { a: Int8Array.from([1]) }, a1)).toBe(true) + }) +}) + +describe('object tracking', () => { + it('should fail without trackMemo', () => { + const proxyCache = new WeakMap() + const s1 = { a: { b: 1, c: 2 } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.b) + expect(isDeepChanged(s1, { a: s1.a }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 3, c: 2 } }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: { b: 1, c: 3 } }, a1)).not.toBe(true) + }) + + it('should work with trackMemo', () => { + const proxyCache = new WeakMap() + const s1 = { a: { b: 1, c: 2 } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.b) + trackMemo(p1.a) + expect(isDeepChanged(s1, { a: s1.a }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 3, c: 2 } }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: { b: 1, c: 3 } }, a1)).toBe(true) + }) + + it('should work with trackMemo in advance', () => { + const proxyCache = new WeakMap() + const s1 = { a: { b: 1, c: 2 } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + trackMemo(p1.a) + noop(p1.a.b) + expect(isDeepChanged(s1, { a: s1.a }, a1)).toBe(false) + expect(isDeepChanged(s1, { a: { b: 3, c: 2 } }, a1)).toBe(true) + expect(isDeepChanged(s1, { a: { b: 1, c: 3 } }, a1)).toBe(true) + }) +}) + +describe('object tracking two level deep', () => { + it('should fail without trackMemo', () => { + const proxyCache = new WeakMap() + const s1 = { x: { a: { b: 1, c: 2 } } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.x.a.b) + expect(isDeepChanged(s1, { x: { a: s1.x.a } }, a1)).toBe(false) + expect(isDeepChanged(s1, { x: { a: { b: 3, c: 2 } } }, a1)).toBe(true) + expect(isDeepChanged(s1, { x: { a: { b: 1, c: 3 } } }, a1)).not.toBe(true) + }) + + it('should work with trackMemo', () => { + const proxyCache = new WeakMap() + const s1 = { x: { a: { b: 1, c: 2 } } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.x.a.b) + trackMemo(p1.x.a) + expect(isDeepChanged(s1, { x: { a: s1.x.a } }, a1)).toBe(false) + expect(isDeepChanged(s1, { x: { a: { b: 3, c: 2 } } }, a1)).toBe(true) + expect(isDeepChanged(s1, { x: { a: { b: 1, c: 3 } } }, a1)).toBe(true) + }) + + it('should work with trackMemo in advance', () => { + const proxyCache = new WeakMap() + const s1 = { x: { a: { b: 1, c: 2 } } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + trackMemo(p1.x.a) + noop(p1.x.a.b) + expect(isDeepChanged(s1, { x: { a: s1.x.a } }, a1)).toBe(false) + expect(isDeepChanged(s1, { x: { a: { b: 3, c: 2 } } }, a1)).toBe(true) + expect(isDeepChanged(s1, { x: { a: { b: 1, c: 3 } } }, a1)).toBe(true) + }) +}) + +describe('object tracking', () => { + it('should get untracked object', () => { + const proxyCache = new WeakMap() + const s1 = { a: { b: 1, c: 2 } } + const a1 = new WeakMap() + const p1 = createDeepProxy(s1, a1, proxyCache) + noop(p1.a.b) + expect(p1).not.toBe(s1) + expect(p1.a).not.toBe(s1.a) + expect(p1.a.b).toBe(s1.a.b) + expect(getUntrackedObject(p1)).toBe(s1) + expect(getUntrackedObject(p1.a)).toBe(s1.a) + expect(getUntrackedObject(p1.a.b)).toBe(null) + }) +})