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