From 919c94dce7367c23ae853c9e6dd4866af458d6f7 Mon Sep 17 00:00:00 2001 From: daishi Date: Mon, 20 Jan 2020 20:00:03 +0900 Subject: [PATCH 1/9] convert useTrackedState from reactive-react-redux --- src/hooks/useTrackedState.js | 54 +++++ src/index.js | 2 + src/utils/deepProxy.js | 149 ++++++++++++++ test/utils/deepProxy.spec.js | 375 +++++++++++++++++++++++++++++++++++ 4 files changed, 580 insertions(+) create mode 100644 src/hooks/useTrackedState.js create mode 100644 src/utils/deepProxy.js create mode 100644 test/utils/deepProxy.spec.js diff --git a/src/hooks/useTrackedState.js b/src/hooks/useTrackedState.js new file mode 100644 index 000000000..50bf829d9 --- /dev/null +++ b/src/hooks/useTrackedState.js @@ -0,0 +1,54 @@ +/* eslint-env es6 */ + +import { useReducer, useRef, useMemo } from 'react' +import { useReduxContext } from './useReduxContext' +import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' +import Subscription from '../utils/Subscription' +import { createDeepProxy, isDeepChanged } from '../utils/deepProxy' + +// TODO createTrackedStateHook for custom context + +export const useTrackedState = () => { + const [, forceUpdate] = useReducer(c => c + 1, 0) + const { store, subscription: contextSub } = useReduxContext() + const state = store.getState() + const subscription = useMemo(() => new Subscription(store, contextSub), [ + store, + contextSub + ]) + const affected = new WeakMap() + const lastTracked = useRef(null) + useIsomorphicLayoutEffect(() => { + lastTracked.current = { + state, + affected, + cache: new WeakMap() + } + }) + useIsomorphicLayoutEffect(() => { + const checkForUpdates = () => { + const nextState = store.getState() + if ( + lastTracked.current.state === nextState || + !isDeepChanged( + lastTracked.current.state, + nextState, + lastTracked.current.affected, + lastTracked.current.cache + ) + ) { + // not changed + return + } + forceUpdate() + } + subscription.onStateChange = checkForUpdates + subscription.trySubscribe() + + checkForUpdates() + + return () => subscription.tryUnsubscribe() + }, [store, subscription]) + const proxyCache = useRef(new WeakMap()) // per-hook proxyCache + return createDeepProxy(state, affected, proxyCache.current) +} diff --git a/src/index.js b/src/index.js index d02c35a07..695cac683 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ 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 } from './hooks/useTrackedState' import { setBatch } from './utils/batch' import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates' @@ -25,5 +26,6 @@ export { createSelectorHook, useStore, createStoreHook, + useTrackedState, shallowEqual } diff --git a/src/utils/deepProxy.js b/src/utils/deepProxy.js new file mode 100644 index 000000000..47d6b4d2a --- /dev/null +++ b/src/utils/deepProxy.js @@ -0,0 +1,149 @@ +/* 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 + 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/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) + }) +}) From 17c266e33d071d0e023d58618120d7041b0b0e13 Mon Sep 17 00:00:00 2001 From: daishi Date: Tue, 21 Jan 2020 21:08:45 +0900 Subject: [PATCH 2/9] createTrackedStateHook for custom context --- src/hooks/useTrackedState.js | 76 +++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/src/hooks/useTrackedState.js b/src/hooks/useTrackedState.js index 50bf829d9..66cbb438f 100644 --- a/src/hooks/useTrackedState.js +++ b/src/hooks/useTrackedState.js @@ -1,47 +1,46 @@ /* eslint-env es6 */ -import { useReducer, useRef, useMemo } from 'react' -import { useReduxContext } from './useReduxContext' -import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' +import { useReducer, useRef, useMemo, useContext } 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' -// TODO createTrackedStateHook for custom context +function useTrackedStateWithStoreAndSubscription(store, contextSub) { + const [, forceRender] = useReducer(s => s + 1, 0) -export const useTrackedState = () => { - const [, forceUpdate] = useReducer(c => c + 1, 0) - const { store, subscription: contextSub } = useReduxContext() - const state = store.getState() const subscription = useMemo(() => new Subscription(store, contextSub), [ store, contextSub ]) + + const state = store.getState() const affected = new WeakMap() - const lastTracked = useRef(null) + const latestTracked = useRef(null) useIsomorphicLayoutEffect(() => { - lastTracked.current = { + latestTracked.current = { state, affected, cache: new WeakMap() } }) useIsomorphicLayoutEffect(() => { - const checkForUpdates = () => { + function checkForUpdates() { const nextState = store.getState() if ( - lastTracked.current.state === nextState || - !isDeepChanged( - lastTracked.current.state, + latestTracked.current.state !== nextState && + isDeepChanged( + latestTracked.current.state, nextState, - lastTracked.current.affected, - lastTracked.current.cache + latestTracked.current.affected, + latestTracked.current.cache ) ) { - // not changed - return + forceRender() } - forceUpdate() } + subscription.onStateChange = checkForUpdates subscription.trySubscribe() @@ -49,6 +48,45 @@ export const useTrackedState = () => { return () => subscription.tryUnsubscribe() }, [store, subscription]) + 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() From 390921b2a1452374bdb8cd09ef61c5150542fdb9 Mon Sep 17 00:00:00 2001 From: daishi Date: Thu, 23 Jan 2020 23:15:20 +0900 Subject: [PATCH 3/9] add spec for useTrackedState --- src/index.js | 6 +- test/hooks/useTrackedState.spec.js | 265 +++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 test/hooks/useTrackedState.spec.js diff --git a/src/index.js b/src/index.js index 695cac683..34204cb15 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +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 } from './hooks/useTrackedState' +import { + useTrackedState, + createTrackedStateHook +} from './hooks/useTrackedState' import { setBatch } from './utils/batch' import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates' @@ -27,5 +30,6 @@ export { useStore, createStoreHook, useTrackedState, + createTrackedStateHook, shallowEqual } 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) + }) + }) + }) +}) From 6377cd6a141b32be400094fda762b2911f4f5586 Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 26 Jan 2020 15:32:21 +0900 Subject: [PATCH 4/9] add docs --- docs/api/proxy-based-tracking.md | 145 +++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/api/proxy-based-tracking.md diff --git a/docs/api/proxy-based-tracking.md b/docs/api/proxy-based-tracking.md new file mode 100644 index 000000000..7b90554c0 --- /dev/null +++ b/docs/api/proxy-based-tracking.md @@ -0,0 +1,145 @@ +--- +id: proxy-based-tracking +title: Proxy-based Tracking +sidebar_label: Proxy-based Tracking +hide_title: true +--- + +# Proxy-based Tracking + +This document describes about `useTrackedState` hook. + +## How does this get used? + +`useTrackedState` is a hook that can be used instead of `useSelector`. +It doesn't mean to replace `useSelector` completely. +It gives a new way of connecting Redux store to React. + +> **Note**: It's not completely new in the sense that there already exists a library for `connect`: [beautiful-react-redux](https://github.com/theKashey/beautiful-react-redux) + +The usage of `useTrackedState` is extremely simple. + +```jsx +import React from 'react' +import { useTrackedState } from 'react-redux' + +export const CounterComponent = () => { + const { counter } = useTrackedState() + return
{counter}
+} +``` + +Using props is intuitive. + +```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: Far easier to understand Redux and R-R without the notion of selectors +> +> For intermediates: Never needs to worry about memoized selectors +> +> For experts: No stale props issue + +## 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. + +### Caveats + +Proxy-based tracking may not work 100% as expected. + +> - 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 shouldn't be used outside of render +> +> ```js +> const state = useTrackedState(); +> const dispatch = useUpdate(); +> dispatch({ type: 'FOO', value: state.foo }); // This may lead unexpected behavior if state.foo is an object +> dispatch({ type: 'FOO', value: state.fooStr }); // This is OK if state.fooStr is a string +> ``` +> +> It's recommended to use primitive values for `dispatch`, `setState` and others. + +### 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, and React Native (JavaScript Core). + +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 initiall 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. From 63b52cc67febf106aa94ceb14f1f1bc8b4e0b1ca Mon Sep 17 00:00:00 2001 From: Daishi Kato Date: Mon, 27 Jan 2020 07:53:38 +0900 Subject: [PATCH 5/9] Update docs/api/proxy-based-tracking.md Co-Authored-By: Mark Erikson --- docs/api/proxy-based-tracking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/proxy-based-tracking.md b/docs/api/proxy-based-tracking.md index 7b90554c0..85670203d 100644 --- a/docs/api/proxy-based-tracking.md +++ b/docs/api/proxy-based-tracking.md @@ -140,6 +140,6 @@ state.count state.foo ``` -So, if the state shape is defined initiall and never changed, it should be fine. +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. From 9fcdbd386876b59f8eb44c9521a9fbbf11580710 Mon Sep 17 00:00:00 2001 From: daishi Date: Mon, 27 Jan 2020 08:42:41 +0900 Subject: [PATCH 6/9] apply suggestions in docs --- docs/api/proxy-based-tracking.md | 139 ++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 50 deletions(-) diff --git a/docs/api/proxy-based-tracking.md b/docs/api/proxy-based-tracking.md index 85670203d..2bf6cc349 100644 --- a/docs/api/proxy-based-tracking.md +++ b/docs/api/proxy-based-tracking.md @@ -11,13 +11,14 @@ This document describes about `useTrackedState` hook. ## How does this get used? -`useTrackedState` is a hook that can be used instead of `useSelector`. -It doesn't mean to replace `useSelector` completely. -It gives a new way of connecting Redux store to React. +`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's not completely new in the sense that there already exists a library for `connect`: [beautiful-react-redux](https://github.com/theKashey/beautiful-react-redux) +> **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 extremely simple. +The usage of `useTrackedState` is like the following. ```jsx import React from 'react' @@ -29,7 +30,7 @@ export const CounterComponent = () => { } ``` -Using props is intuitive. +If it needs to use props, it can be done so. ```jsx import React from 'react' @@ -44,11 +45,49 @@ export const TodoListItem = props => { ## Why would you want to use it? -> For beginners: Far easier to understand Redux and R-R without the notion of selectors -> -> For intermediates: Never needs to worry about memoized selectors -> -> For experts: No stale props issue +### 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](https://react-redux.js.org/api/hooks#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? @@ -76,45 +115,45 @@ Whereas with useTrackedState, a component re-renders whenever the `age` value is ### Caveats -Proxy-based tracking may not work 100% as expected. - -> - 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 shouldn't be used outside of render -> -> ```js -> const state = useTrackedState(); -> const dispatch = useUpdate(); -> dispatch({ type: 'FOO', value: state.foo }); // This may lead unexpected behavior if state.foo is an object -> dispatch({ type: 'FOO', value: state.fooStr }); // This is OK if state.fooStr is a string -> ``` -> -> It's recommended to use primitive values for `dispatch`, `setState` and others. +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 shouldn't be used outside of render + +```js +const state = useTrackedState(); +const dispatch = useUpdate(); +dispatch({ type: 'FOO', value: state.foo }); // This may lead unexpected behavior if state.foo is an object +dispatch({ type: 'FOO', value: state.fooStr }); // This is OK if state.fooStr is a string +``` + +It's recommended to use primitive values for `dispatch`, `setState` and others. ### Performance From 2ecb0b201f4335a49270ccc3d954364c881b8d05 Mon Sep 17 00:00:00 2001 From: daishi Date: Mon, 27 Jan 2020 08:47:57 +0900 Subject: [PATCH 7/9] move useTrackedState docs into hooks.md --- docs/api/hooks.md | 176 +++++++++++++++++++++++++++++ docs/api/proxy-based-tracking.md | 184 ------------------------------- 2 files changed, 176 insertions(+), 184 deletions(-) delete mode 100644 docs/api/proxy-based-tracking.md diff --git a/docs/api/hooks.md b/docs/api/hooks.md index 1dce44e0a..c73d4472e 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -290,6 +290,182 @@ 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. + +#### 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 shouldn't be used outside of render + +```js +const state = useTrackedState(); +const dispatch = useUpdate(); +dispatch({ type: 'FOO', value: state.foo }); // This may lead unexpected behavior if state.foo is an object +dispatch({ type: 'FOO', value: state.fooStr }); // This is OK if state.fooStr is a string +``` + +It's recommended to use primitive values for `dispatch`, `setState` and others. + +#### 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, and React Native (JavaScript Core). + +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/docs/api/proxy-based-tracking.md b/docs/api/proxy-based-tracking.md deleted file mode 100644 index 2bf6cc349..000000000 --- a/docs/api/proxy-based-tracking.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -id: proxy-based-tracking -title: Proxy-based Tracking -sidebar_label: Proxy-based Tracking -hide_title: true ---- - -# Proxy-based Tracking - -This document describes about `useTrackedState` hook. - -## 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](https://react-redux.js.org/api/hooks#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. - -### 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 shouldn't be used outside of render - -```js -const state = useTrackedState(); -const dispatch = useUpdate(); -dispatch({ type: 'FOO', value: state.foo }); // This may lead unexpected behavior if state.foo is an object -dispatch({ type: 'FOO', value: state.fooStr }); // This is OK if state.fooStr is a string -``` - -It's recommended to use primitive values for `dispatch`, `setState` and others. - -### 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, and React Native (JavaScript Core). - -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. From c3d5bbcce41305ed07c3e58abcbd84a62ba0b282 Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 1 Mar 2020 17:07:36 +0900 Subject: [PATCH 8/9] useDebugValue for tracked info, always unwrap proxy, update 3rd caveat --- docs/api/hooks.md | 39 ++++++++++++++++++++++++++++++------ src/hooks/useTrackedState.js | 38 ++++++++++++++++++++++++++++++++++- src/utils/deepProxy.js | 2 ++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/docs/api/hooks.md b/docs/api/hooks.md index c73d4472e..bf64d41c7 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -396,6 +396,19 @@ 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. @@ -427,17 +440,31 @@ const Child = React.memo(({ foo }) => { It's recommended to use primitive values for props with memo'd components. -- Proxied state shouldn't be used outside of render +- 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.foo }); // This may lead unexpected behavior if state.foo is an object -dispatch({ type: 'FOO', value: state.fooStr }); // This is OK if state.fooStr is a string +dispatch({ type: 'FOO', value: state.fooObj }); // Instead of using objects, +dispatch({ type: 'FOO', value: state.fooStr }); // Use primitives. ``` -It's recommended to use primitive values for `dispatch`, `setState` and others. - #### Performance useSelector is sometimes more performant because Proxies are overhead. @@ -446,7 +473,7 @@ useTrackedState is sometimes more performant because it doesn't need to invoke a ### What are the limitations in browser support? -Proxies are not supported in old browsers like IE11, and React Native (JavaScript Core). +Proxies are not supported in old browsers like IE11. However, one could use [proxy-polyfill](https://github.com/GoogleChrome/proxy-polyfill) with care. diff --git a/src/hooks/useTrackedState.js b/src/hooks/useTrackedState.js index 66cbb438f..ea2769c8d 100644 --- a/src/hooks/useTrackedState.js +++ b/src/hooks/useTrackedState.js @@ -1,12 +1,44 @@ /* eslint-env es6 */ -import { useReducer, useRef, useMemo, useContext } from 'react' +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) +} + function useTrackedStateWithStoreAndSubscription(store, contextSub) { const [, forceRender] = useReducer(s => s + 1, 0) @@ -49,6 +81,10 @@ function useTrackedStateWithStoreAndSubscription(store, contextSub) { 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) } diff --git a/src/utils/deepProxy.js b/src/utils/deepProxy.js index 47d6b4d2a..e5f42e50a 100644 --- a/src/utils/deepProxy.js +++ b/src/utils/deepProxy.js @@ -66,6 +66,8 @@ const createProxyHandler = () => ({ 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() From c01ddc058f76f352a3ed63d9f711600a5c43bf88 Mon Sep 17 00:00:00 2001 From: daishi Date: Mon, 20 Apr 2020 07:30:29 +0900 Subject: [PATCH 9/9] minor fix in useAffectedDebugValue --- src/hooks/useTrackedState.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useTrackedState.js b/src/hooks/useTrackedState.js index ea2769c8d..ff7284ee2 100644 --- a/src/hooks/useTrackedState.js +++ b/src/hooks/useTrackedState.js @@ -36,7 +36,7 @@ const useAffectedDebugValue = (state, affected) => { useEffect(() => { pathList.current = affectedToPathList(state, affected) }) - useDebugValue(pathList) + useDebugValue(pathList.current) } function useTrackedStateWithStoreAndSubscription(store, contextSub) {