diff --git a/jest.config.js b/jest.config.js index 11296c56a..dbf286575 100644 --- a/jest.config.js +++ b/jest.config.js @@ -51,8 +51,8 @@ const nextEntryConfig = { module.exports = { projects: [ tsStandardConfig, - rnConfig, - standardReact17Config, - nextEntryConfig, + //rnConfig, + //standardReact17Config, + //nextEntryConfig, ], } diff --git a/src/components/Context.ts b/src/components/Context.ts index 6c5607a8f..1fab94942 100644 --- a/src/components/Context.ts +++ b/src/components/Context.ts @@ -3,6 +3,7 @@ import type { Context } from 'react' import type { Action, AnyAction, Store } from 'redux' import type { Subscription } from '../utils/Subscription' import type { CheckFrequency } from '../hooks/useSelector' +import type { Node } from '../utils/autotracking/tracking' export interface ReactReduxContextValue< SS = any, @@ -13,6 +14,7 @@ export interface ReactReduxContextValue< getServerState?: () => SS stabilityCheck: CheckFrequency noopCheck: CheckFrequency + trackingNode: Node> } const ContextKey = Symbol.for(`react-redux-context`) diff --git a/src/components/Provider.tsx b/src/components/Provider.tsx index 170860c21..f68363e36 100644 --- a/src/components/Provider.tsx +++ b/src/components/Provider.tsx @@ -6,6 +6,7 @@ import { createSubscription } from '../utils/Subscription' import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import type { Action, AnyAction, Store } from 'redux' import type { CheckFrequency } from '../hooks/useSelector' +import { createNode, updateNode } from '../utils/autotracking/proxy' export interface ProviderProps { /** @@ -42,14 +43,21 @@ function Provider({ stabilityCheck = 'once', noopCheck = 'once', }: ProviderProps) { - const contextValue = React.useMemo(() => { - const subscription = createSubscription(store) + const contextValue: ReactReduxContextValue = React.useMemo(() => { + const trackingNode = createNode(store.getState() as any) + //console.log('Created tracking node: ', trackingNode) + const subscription = createSubscription( + store as any, + undefined, + trackingNode + ) return { - store, + store: store as any, subscription, getServerState: serverState ? () => serverState : undefined, stabilityCheck, noopCheck, + trackingNode, } }, [store, serverState, stabilityCheck, noopCheck]) diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index c0363b4a5..a31c9403f 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -1,4 +1,4 @@ -import { useCallback, useDebugValue, useRef } from 'react' +import { useCallback, useDebugValue, useMemo, useRef } from 'react' import { createReduxContextHook, @@ -8,6 +8,9 @@ import { ReactReduxContext } from '../components/Context' import type { EqualityFn, NoInfer } from '../types' import type { uSESWS } from '../utils/useSyncExternalStore' import { notInitialized } from '../utils/useSyncExternalStore' +import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' +import { createCache } from '../utils/autotracking/autotracking' +import { CacheWrapper } from '../utils/Subscription' export type CheckFrequency = 'never' | 'once' | 'always' @@ -80,6 +83,7 @@ export function createSelectorHook(context = ReactReduxContext): UseSelector { getServerState, stabilityCheck: globalStabilityCheck, noopCheck: globalNoopCheck, + trackingNode, } = useReduxContext()! const firstRun = useRef(true) @@ -136,11 +140,52 @@ export function createSelectorHook(context = ReactReduxContext): UseSelector { [selector, globalStabilityCheck, stabilityCheck] ) + const latestWrappedSelectorRef = useRef(wrappedSelector) + + // console.log( + // 'Writing latest selector. Same reference? ', + // wrappedSelector === latestWrappedSelectorRef.current + // ) + latestWrappedSelectorRef.current = wrappedSelector + + const cache = useMemo(() => { + //console.log('Recreating cache') + const cache = createCache(() => { + // console.log('Wrapper cache called: ', store.getState()) + //return latestWrappedSelectorRef.current(trackingNode.proxy as TState) + return wrappedSelector(trackingNode.proxy as TState) + }) + return cache + }, [trackingNode, wrappedSelector]) + + const cacheWrapper = useRef({ cache } as CacheWrapper) + + useIsomorphicLayoutEffect(() => { + cacheWrapper.current.cache = cache + }) + + const subscribeToStore = useMemo(() => { + const subscribeToStore = (onStoreChange: () => void) => { + const wrappedOnStoreChange = () => { + // console.log('wrappedOnStoreChange') + return onStoreChange() + } + console.log('Subscribing to store with tracking') + return subscription.addNestedSub(wrappedOnStoreChange, { + trigger: 'tracked', + cache: cacheWrapper.current, + }) + } + return subscribeToStore + }, [subscription]) + const selectedState = useSyncExternalStoreWithSelector( - subscription.addNestedSub, + //subscription.addNestedSub, + subscribeToStore, store.getState, + //() => trackingNode.proxy as TState, getServerState || store.getState, - wrappedSelector, + cache.getValue, equalityFn ) diff --git a/src/utils/Subscription.ts b/src/utils/Subscription.ts index 76290be4a..9e27e0c14 100644 --- a/src/utils/Subscription.ts +++ b/src/utils/Subscription.ts @@ -1,4 +1,13 @@ +import type { Store } from 'redux' import { getBatch } from './batch' +import type { Node } from './autotracking/tracking' + +import { + createCache, + TrackingCache, + $REVISION, +} from './autotracking/autotracking' +import { updateNode } from './autotracking/proxy' // encapsulates the subscription logic for connecting a component to the redux store, as // well as nesting subscriptions of descendant components, so that we can ensure the @@ -6,10 +15,16 @@ import { getBatch } from './batch' type VoidFunc = () => void +export interface CacheWrapper { + cache: TrackingCache +} + type Listener = { callback: VoidFunc next: Listener | null prev: Listener | null + trigger: 'always' | 'tracked' + selectorCache?: CacheWrapper } function createListenerCollection() { @@ -24,10 +39,29 @@ function createListenerCollection() { }, notify() { + console.log('Notifying subscribers') batch(() => { let listener = first while (listener) { - listener.callback() + console.log('Listener: ', listener) + if (listener.trigger == 'tracked') { + if (listener.selectorCache!.cache.needsRecalculation()) { + console.log('Calling subscriber due to recalc need') + console.log( + 'Calling subscriber due to recalc. Revision before: ', + $REVISION + ) + listener.callback() + console.log('Revision after: ', $REVISION) + } else { + console.log( + 'Skipping subscriber, no recalc: ', + listener.selectorCache + ) + } + } else { + listener.callback() + } listener = listener.next } }) @@ -43,13 +77,29 @@ function createListenerCollection() { return listeners }, - subscribe(callback: () => void) { + subscribe( + callback: () => void, + options: AddNestedSubOptions = { trigger: 'always' } + ) { let isSubscribed = true + console.log('Adding listener: ', options.trigger) + let listener: Listener = (last = { callback, next: null, prev: last, + trigger: options.trigger, + selectorCache: + options.trigger === 'tracked' ? options.cache! : undefined, + // subscriberCache: + // options.trigger === 'tracked' + // ? createCache(() => { + // console.log('Calling subscriberCache') + // listener.selectorCache!.get() + // callback() + // }) + // : undefined, }) if (listener.prev) { @@ -79,13 +129,18 @@ function createListenerCollection() { type ListenerCollection = ReturnType +interface AddNestedSubOptions { + trigger: 'always' | 'tracked' + cache?: CacheWrapper +} + export interface Subscription { - addNestedSub: (listener: VoidFunc) => VoidFunc + addNestedSub: (listener: VoidFunc, options?: AddNestedSubOptions) => VoidFunc notifyNestedSubs: VoidFunc handleChangeWrapper: VoidFunc isSubscribed: () => boolean onStateChange?: VoidFunc | null - trySubscribe: VoidFunc + trySubscribe: (options?: AddNestedSubOptions) => void tryUnsubscribe: VoidFunc getListeners: () => ListenerCollection } @@ -95,16 +150,28 @@ const nullListeners = { get: () => [], } as unknown as ListenerCollection -export function createSubscription(store: any, parentSub?: Subscription) { +export function createSubscription( + store: Store, + parentSub?: Subscription, + trackingNode?: Node +) { let unsubscribe: VoidFunc | undefined let listeners: ListenerCollection = nullListeners - function addNestedSub(listener: () => void) { - trySubscribe() - return listeners.subscribe(listener) + function addNestedSub( + listener: () => void, + options: AddNestedSubOptions = { trigger: 'always' } + ) { + console.log('addNestedSub: ', options) + trySubscribe(options) + return listeners.subscribe(listener, options) } function notifyNestedSubs() { + if (store && trackingNode) { + console.log('Updating node in notifyNestedSubs') + updateNode(trackingNode, store.getState()) + } listeners.notify() } @@ -118,10 +185,11 @@ export function createSubscription(store: any, parentSub?: Subscription) { return Boolean(unsubscribe) } - function trySubscribe() { + function trySubscribe(options: AddNestedSubOptions = { trigger: 'always' }) { if (!unsubscribe) { + console.log('trySubscribe, parentSub: ', parentSub) unsubscribe = parentSub - ? parentSub.addNestedSub(handleChangeWrapper) + ? parentSub.addNestedSub(handleChangeWrapper, options) : store.subscribe(handleChangeWrapper) listeners = createListenerCollection() diff --git a/src/utils/autotracking/autotracking.ts b/src/utils/autotracking/autotracking.ts new file mode 100644 index 000000000..79e9a5c04 --- /dev/null +++ b/src/utils/autotracking/autotracking.ts @@ -0,0 +1,238 @@ +// Original autotracking implementation source: +// - https://gist.github.com/pzuraq/79bf862e0f8cd9521b79c4b6eccdc4f9 +// Additional references: +// - https://www.pzuraq.com/blog/how-autotracking-works +// - https://v5.chriskrycho.com/journal/autotracking-elegant-dx-via-cutting-edge-cs/ +import { assert } from './utils' + +// The global revision clock. Every time state changes, the clock increments. +export let $REVISION = 0 + +// The current dependency tracker. Whenever we compute a cache, we create a Set +// to track any dependencies that are used while computing. If no cache is +// computing, then the tracker is null. +let CURRENT_TRACKER: Set | TrackingCache> | null = null + +type EqualityFn = (a: any, b: any) => boolean + +// Storage represents a root value in the system - the actual state of our app. +export class Cell { + revision = $REVISION + + _value: T + _lastValue: T + _isEqual: EqualityFn = tripleEq + _name: string | undefined + + constructor(initialValue: T, isEqual: EqualityFn = tripleEq, name?: string) { + this._value = this._lastValue = initialValue + this._isEqual = isEqual + this._name = name + } + + // Whenever a storage value is read, it'll add itself to the current tracker if + // one exists, entangling its state with that cache. + get value() { + CURRENT_TRACKER?.add(this) + + return this._value + } + + // Whenever a storage value is updated, we bump the global revision clock, + // assign the revision for this storage to the new value, _and_ we schedule a + // rerender. This is important, and it's what makes autotracking _pull_ + // based. We don't actively tell the caches which depend on the storage that + // anything has happened. Instead, we recompute the caches when needed. + set value(newValue) { + if (this.value === newValue) return + + this._value = newValue + this.revision = ++$REVISION + } +} + +function tripleEq(a: unknown, b: unknown) { + return a === b +} + +// Caches represent derived state in the system. They are ultimately functions +// that are memoized based on what state they use to produce their output, +// meaning they will only rerun IFF a storage value that could affect the output +// has changed. Otherwise, they'll return the cached value. +export class TrackingCache { + _cachedValue: any + _cachedRevision = -1 + _deps: Cell[] = [] + hits = 0 + _needsRecalculation = false + + fn: (...args: any[]) => any + + constructor(fn: (...args: any[]) => any) { + this.fn = fn + } + + clear() { + this._cachedValue = undefined + this._cachedRevision = -1 + this._deps = [] + this.hits = 0 + this._needsRecalculation = false + } + + getValue = () => { + //console.log('TrackedCache getValue') + return this.value + } + + needsRecalculation() { + if (!this._needsRecalculation) { + this._needsRecalculation = + this.revision > this._cachedRevision || this._cachedRevision === -1 + } + console.log( + 'Needs recalculation: ', + this._needsRecalculation, + this._cachedRevision, + this._cachedValue + ) + return this._needsRecalculation + } + + /* + getWithArgs = (...args: any[]) => { + // console.log( + // `TrackingCache value: revision = ${this.revision}, cachedRevision = ${this._cachedRevision}, value = ${this._cachedValue}` + // ) + // When getting the value for a Cache, first we check all the dependencies of + // the cache to see what their current revision is. If the current revision is + // greater than the cached revision, then something has changed. + //if (this.revision > this._cachedRevision) { + if (this.needsRecalculation()) { + const { fn } = this + + // We create a new dependency tracker for this cache. As the cache runs + // its function, any Storage or Cache instances which are used while + // computing will be added to this tracker. In the end, it will be the + // full list of dependencies that this Cache depends on. + const currentTracker = new Set>() + const prevTracker = CURRENT_TRACKER + + CURRENT_TRACKER = currentTracker + + // try { + this._cachedValue = fn.apply(null, args) + // } finally { + CURRENT_TRACKER = prevTracker + this.hits++ + this._deps = Array.from(currentTracker) + + // Set the cached revision. This is the current clock count of all the + // dependencies. If any dependency changes, this number will be less + // than the new revision. + this._cachedRevision = this.revision + // } + } + + // If there is a current tracker, it means another Cache is computing and + // using this one, so we add this one to the tracker. + CURRENT_TRACKER?.add(this) + + // Always return the cached value. + return this._cachedValue + } +*/ + get value() { + // console.log( + // `TrackingCache value: revision = ${this.revision}, cachedRevision = ${this._cachedRevision}, value = ${this._cachedValue}` + // ) + // When getting the value for a Cache, first we check all the dependencies of + // the cache to see what their current revision is. If the current revision is + // greater than the cached revision, then something has changed. + if (this.needsRecalculation()) { + const { fn } = this + + // We create a new dependency tracker for this cache. As the cache runs + // its function, any Storage or Cache instances which are used while + // computing will be added to this tracker. In the end, it will be the + // full list of dependencies that this Cache depends on. + const currentTracker = new Set>() + const prevTracker = CURRENT_TRACKER + + CURRENT_TRACKER = currentTracker + + // try { + this._cachedValue = fn() + // } finally { + CURRENT_TRACKER = prevTracker + this.hits++ + this._deps = Array.from(currentTracker) + + // Set the cached revision. This is the current clock count of all the + // dependencies. If any dependency changes, this number will be less + // than the new revision. + this._cachedRevision = this.revision + this._needsRecalculation = false + + // console.log('Value: ', this._cachedValue, 'deps: ', this._deps) + // } + } + + // If there is a current tracker, it means another Cache is computing and + // using this one, so we add this one to the tracker. + CURRENT_TRACKER?.add(this) + + // Always return the cached value. + return this._cachedValue + } + + get revision() { + // console.log('Calculating revision: ', { + // value: this._cachedValue, + // deps: this._deps.map((d) => d._name), + // }) + // The current revision is the max of all the dependencies' revisions. + return Math.max(...this._deps.map((d) => d.revision), 0) + } +} + +export function getValue(cell: Cell): T { + if (!(cell instanceof Cell)) { + console.warn('Not a valid cell! ', cell) + } + + return cell.value +} + +type CellValue> = T extends Cell ? U : never + +export function setValue>( + storage: T, + value: CellValue +): void { + assert( + storage instanceof Cell, + 'setValue must be passed a tracked store created with `createStorage`.' + ) + + storage.value = storage._lastValue = value +} + +export function createCell( + initialValue: T, + isEqual: EqualityFn = tripleEq, + name?: string +): Cell { + return new Cell(initialValue, isEqual, name) +} + +export function createCache( + fn: (...args: any[]) => T +): TrackingCache { + assert( + typeof fn === 'function', + 'the first parameter to `createCache` must be a function' + ) + + return new TrackingCache(fn) +} diff --git a/src/utils/autotracking/proxy.ts b/src/utils/autotracking/proxy.ts new file mode 100644 index 000000000..682e35452 --- /dev/null +++ b/src/utils/autotracking/proxy.ts @@ -0,0 +1,238 @@ +// Original source: +// - https://github.com/simonihmig/tracked-redux/blob/master/packages/tracked-redux/src/-private/proxy.ts + +import { + consumeCollection, + dirtyCollection, + Node, + Tag, + consumeTag, + dirtyTag, + createTag, +} from './tracking' + +export const REDUX_PROXY_LABEL = Symbol() + +let nextId = 0 + +const proto = Object.getPrototypeOf({}) + +class ObjectTreeNode> implements Node { + proxy: T = new Proxy(this, objectProxyHandler) as unknown as T + tag = createTag('object') + tags = {} as Record + children = {} as Record + collectionTag = null + id = nextId++ + + constructor(public value: T) { + this.value = value + this.tag.value = value + } +} + +const objectProxyHandler = { + get(node: Node, key: string | symbol): unknown { + //console.log('Reading key: ', key, node.value) + + function calculateResult() { + const { value } = node + + const childValue = Reflect.get(value, key) + + if (typeof key === 'symbol') { + return childValue + } + + if (key in proto) { + return childValue + } + + if (typeof childValue === 'object' && childValue !== null) { + let childNode = node.children[key] + + if (childNode === undefined) { + childNode = node.children[key] = createNode(childValue) + } + + if (childNode.tag) { + consumeTag(childNode.tag) + } + + return childNode.proxy + } else { + let tag = node.tags[key] + + if (tag === undefined) { + tag = node.tags[key] = createTag(key) + tag.value = childValue + } + + consumeTag(tag) + + return childValue + } + } + const res = calculateResult() + return res + }, + + ownKeys(node: Node): ArrayLike { + consumeCollection(node) + return Reflect.ownKeys(node.value) + }, + + getOwnPropertyDescriptor( + node: Node, + prop: string | symbol + ): PropertyDescriptor | undefined { + return Reflect.getOwnPropertyDescriptor(node.value, prop) + }, + + has(node: Node, prop: string | symbol): boolean { + return Reflect.has(node.value, prop) + }, +} + +class ArrayTreeNode> implements Node { + proxy: T = new Proxy([this], arrayProxyHandler) as unknown as T + tag = createTag('array') + tags = {} + children = {} + collectionTag = null + id = nextId++ + + constructor(public value: T) { + this.value = value + this.tag.value = value + } +} + +const arrayProxyHandler = { + get([node]: [Node], key: string | symbol): unknown { + if (key === 'length') { + consumeCollection(node) + } + + return objectProxyHandler.get(node, key) + }, + + ownKeys([node]: [Node]): ArrayLike { + return objectProxyHandler.ownKeys(node) + }, + + getOwnPropertyDescriptor( + [node]: [Node], + prop: string | symbol + ): PropertyDescriptor | undefined { + return objectProxyHandler.getOwnPropertyDescriptor(node, prop) + }, + + has([node]: [Node], prop: string | symbol): boolean { + return objectProxyHandler.has(node, prop) + }, +} + +export function createNode | Record>( + value: T +): Node { + if (Array.isArray(value)) { + return new ArrayTreeNode(value) + } + + return new ObjectTreeNode(value) as Node +} + +const keysMap = new WeakMap< + Array | Record, + Set +>() + +export function updateNode | Record>( + node: Node, + newValue: T +): void { + const { value, tags, children } = node + + //console.log('Inside updateNode', newValue) + + node.value = newValue + + if ( + Array.isArray(value) && + Array.isArray(newValue) && + value.length !== newValue.length + ) { + dirtyCollection(node) + } else { + if (value !== newValue) { + let oldKeysSize = 0 + let newKeysSize = 0 + let anyKeysAdded = false + + for (const _key in value) { + oldKeysSize++ + } + + for (const key in newValue) { + newKeysSize++ + if (!(key in value)) { + anyKeysAdded = true + break + } + } + + const isDifferent = anyKeysAdded || oldKeysSize !== newKeysSize + + if (isDifferent) { + dirtyCollection(node) + } + } + } + + for (const key in tags) { + //console.log('Checking tag: ', key) + const childValue = (value as Record)[key] + const newChildValue = (newValue as Record)[key] + + if (childValue !== newChildValue) { + dirtyCollection(node) + dirtyTag(tags[key], newChildValue) + } + + if (typeof newChildValue === 'object' && newChildValue !== null) { + delete tags[key] + } + } + + for (const key in children) { + //console.log(`Checking node: key = ${key}, value = ${children[key]}`) + const childNode = children[key] + const newChildValue = (newValue as Record)[key] + + const childValue = childNode.value + + if (childValue === newChildValue) { + continue + } else if (typeof newChildValue === 'object' && newChildValue !== null) { + console.log('Updating node key: ', key) + updateNode(childNode, newChildValue as Record) + } else { + deleteNode(childNode) + delete children[key] + } + } +} + +function deleteNode(node: Node): void { + if (node.tag) { + dirtyTag(node.tag, null) + } + dirtyCollection(node) + for (const key in node.tags) { + dirtyTag(node.tags[key], null) + } + for (const key in node.children) { + deleteNode(node.children[key]) + } +} diff --git a/src/utils/autotracking/tracking.ts b/src/utils/autotracking/tracking.ts new file mode 100644 index 000000000..5693ac563 --- /dev/null +++ b/src/utils/autotracking/tracking.ts @@ -0,0 +1,50 @@ +import { + createCell as createStorage, + getValue as consumeTag, + setValue, + Cell, +} from './autotracking' + +export type Tag = Cell + +const neverEq = (a: any, b: any): boolean => false + +export function createTag(name?: string): Tag { + return createStorage(null, neverEq, name) +} +export { consumeTag } +export function dirtyTag(tag: Tag, value: any): void { + setValue(tag, value) +} + +export interface Node< + T extends Array | Record = + | Array + | Record +> { + collectionTag: Tag | null + tag: Tag | null + tags: Record + children: Record + proxy: T + value: T + id: number +} + +export const consumeCollection = (node: Node): void => { + let tag = node.collectionTag + + if (tag === null) { + tag = node.collectionTag = createTag(node.collectionTag?._name || 'Unknown') + } + + consumeTag(tag) +} + +export const dirtyCollection = (node: Node): void => { + const tag = node.collectionTag + + if (tag !== null) { + dirtyTag(tag, null) + } +} diff --git a/src/utils/autotracking/utils.ts b/src/utils/autotracking/utils.ts new file mode 100644 index 000000000..cef655a08 --- /dev/null +++ b/src/utils/autotracking/utils.ts @@ -0,0 +1,9 @@ +export function assert( + condition: any, + msg = 'Assertion failed!' +): asserts condition { + if (!condition) { + console.error(msg) + throw new Error(msg) + } +} diff --git a/test/components/Provider.spec.tsx b/test/components/Provider.spec.tsx index 5bfddec3d..9500ef41d 100644 --- a/test/components/Provider.spec.tsx +++ b/test/components/Provider.spec.tsx @@ -217,8 +217,14 @@ describe('React', () => { type: string body: string } - function stringBuilder(prev = '', action: ActionType) { - return action.type === 'APPEND' ? prev + action.body : prev + interface StringState { + string: string + } + + function stringBuilder(state = { string: '' }, action: ActionType) { + return action.type === 'APPEND' + ? { string: state.string + action.body } + : state } const store: Store = createStore(stringBuilder) @@ -244,10 +250,10 @@ describe('React', () => { {}, unknown, ChildContainerProps, - string + StringState >((state, parentProps) => { childMapStateInvokes++ - childCalls.push([state, parentProps.parentState]) + childCalls.push([state.string, parentProps.parentState]) // The state from parent props should always be consistent with the current state return {} })(ChildContainer) diff --git a/test/components/connect.spec.tsx b/test/components/connect.spec.tsx index ad755768e..6d482621a 100644 --- a/test/components/connect.spec.tsx +++ b/test/components/connect.spec.tsx @@ -76,8 +76,14 @@ describe('React', () => { type: string body: any } - function stringBuilder(prev = '', action: ActionType) { - return action.type === 'APPEND' ? prev + action.body : prev + interface StringState { + string: string + } + + function stringBuilder(state = { string: '' }, action: ActionType) { + return action.type === 'APPEND' + ? { string: state.string + action.body } + : state } afterEach(() => rtl.cleanup()) @@ -155,9 +161,9 @@ describe('React', () => { return } } - const ConnectedContainer = connect((state) => ({ string: state }))( - Container - ) + const ConnectedContainer = connect((state: StringState) => ({ + string: state.string, + }))(Container) const tester = rtl.render( @@ -192,9 +198,9 @@ describe('React', () => { TStateProps, unknown, unknown, - string + StringState >((state) => ({ - string: state, + string: state.string, }))(Container) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) @@ -236,9 +242,9 @@ describe('React', () => { TStateProps, unknown, unknown, - string + StringState >((state) => ({ - string: state, + string: state.string, }))(Container) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) @@ -529,7 +535,7 @@ describe('React', () => { } const ConnectedInner = connect( - (state) => ({ stateThing: state }), + (state: StringState) => ({ stateThing: state.string }), { doSomething }, (stateProps, actionProps, parentProps: InnerPropsType) => ({ ...stateProps, @@ -1196,8 +1202,8 @@ describe('React', () => { ChildrenPropsType, unknown, unknown, - string - >((state) => ({ state }))(Child) + StringState + >((state) => ({ state: state.string }))(Child) const { unmount } = rtl.render( @@ -1477,9 +1483,9 @@ describe('React', () => { TStateProps, TDispatchProps, {}, - string + StringState >( - (state) => ({ string: state }), + (state) => ({ string: state.string }), (dispatch) => ({ dispatch }) )(Container) @@ -1538,7 +1544,7 @@ describe('React', () => { } type TOwnProps = ContainerPropsType type TMergedProps = TOwnProps & TDispatchProps & TStateProps - type RootState = string + type RootState = StringState class Container extends Component { render() { @@ -1552,7 +1558,7 @@ describe('React', () => { TMergedProps, RootState >( - (state) => ({ string: state }), + (state) => ({ string: state.string }), (dispatch: ReduxDispatch) => ({ dispatch }), ( stateProps: { string: string }, @@ -1705,9 +1711,9 @@ describe('React', () => { return } } - const ConnectedContainer = connect((state) => { + const ConnectedContainer = connect((state: StringState) => { mapStateCalls++ - return state === 'aaa' ? { change: 1 } : {} + return state.string === 'aaa' ? { change: 1 } : {} })(Container) rtl.render( @@ -2927,7 +2933,7 @@ describe('React', () => { } const ConnectedContainerA = connect( - (state) => ({ string: state }), + (state: StringState) => ({ string: state.string }), () => ({}), () => ({}), // The `pure` option has been removed @@ -2942,7 +2948,7 @@ describe('React', () => { } const ConnectedContainerB = connect( - (state) => ({ string: state }), + (state: StringState) => ({ string: state.string }), () => ({}), () => ({}), // The `pure` option has been removed @@ -2968,7 +2974,7 @@ describe('React', () => { describe('Subscription and update timing correctness', () => { it('should pass state consistently to mapState', () => { - type RootStateType = string + type RootStateType = StringState const store: Store = createStore(stringBuilder) rtl.act(() => { @@ -2999,7 +3005,7 @@ describe('React', () => { ContainerNoDisPatch, ContainerOwnProps, RootStateType - >((state) => ({ state }))(Container) + >((state) => ({ state: state.string }))(Container) const childCalls: any[] = [] @@ -3020,9 +3026,9 @@ describe('React', () => { RootStateType >((state, parentProps) => { childMapStateInvokes++ - childCalls.push([state, parentProps.parentState]) + childCalls.push([state.string, parentProps.parentState]) // The state from parent props should always be consistent with the current state - expect(state).toEqual(parentProps.parentState) + expect(state.string).toEqual(parentProps.parentState) return {} })(ChildContainer) diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index ae38a10bb..b02728182 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -26,6 +26,7 @@ import type { } from '../../src/index' import type { FunctionComponent, DispatchWithoutAction, ReactNode } from 'react' import type { Store, AnyAction, Action } from 'redux' +import { createSlice, configureStore, PayloadAction } from '@reduxjs/toolkit' import type { UseSelectorOptions } from '../../src/hooks/useSelector' // disable checks by default @@ -89,7 +90,10 @@ describe('React', () => { }) it('selects the state and renders the component when the store updates', () => { - const selector = jest.fn((s: NormalStateType) => s.count) + const selector = jest.fn((s: NormalStateType) => { + //console.log('Running selector: `s.count`') + return s.count + }) let result: number | undefined const Comp = () => { const count = useNormalSelector(selector) @@ -120,17 +124,20 @@ describe('React', () => { describe('lifecycle interactions', () => { it('always uses the latest state', () => { - const store = createStore((c: number = 1): number => c + 1, -1) + // const store = createStore((c: number = 1): number => c + 1, -1) const Comp = () => { - const selector = useCallback((c: number): number => c + 1, []) + const selector = useCallback( + (state: NormalStateType) => state.count + 1, + [] + ) const value = useSelector(selector) renderedItems.push(value) return
} rtl.render( - + ) @@ -138,7 +145,7 @@ describe('React', () => { expect(renderedItems).toEqual([1]) rtl.act(() => { - store.dispatch({ type: '' }) + normalStore.dispatch({ type: '' }) }) expect(renderedItems).toEqual([1, 2]) @@ -250,10 +257,13 @@ describe('React', () => { }) it('works properly with memoized selector with dispatch in Child useLayoutEffect', () => { - const store = createStore((c: number = 1): number => c + 1, -1) + //const store = createStore((c: number = 1): number => c + 1, -1) const Comp = () => { - const selector = useCallback((c: number): number => c, []) + const selector = useCallback( + (state: NormalStateType) => state.count, + [] + ) const count = useSelector(selector) renderedItems.push(count) return @@ -266,14 +276,14 @@ describe('React', () => { const Child = ({ parentCount }: ChildPropsType) => { useLayoutEffect(() => { if (parentCount === 1) { - store.dispatch({ type: '' }) + normalStore.dispatch({ type: '' }) } }, [parentCount]) return
{parentCount}
} rtl.render( - + ) @@ -283,7 +293,7 @@ describe('React', () => { // This dispatch triggers another dispatch in useLayoutEffect rtl.act(() => { - store.dispatch({ type: '' }) + normalStore.dispatch({ type: '' }) }) expect(renderedItems).toEqual([0, 1, 2]) @@ -440,6 +450,112 @@ describe('React', () => { expect(selector).toHaveBeenCalledTimes(2) expect(renderedItems.length).toEqual(2) }) + + it('only re-runs selectors if the referenced fields actually change', () => { + interface StateType { + count1: number + count2: number + count3: number + } + + const initialState: StateType = { + count1: 0, + count2: 0, + count3: 0, + } + + const countersSlice = createSlice({ + name: 'counters', + initialState, + reducers: { + increment1: (state) => { + state.count1++ + }, + increment2: (state) => { + state.count2++ + }, + increment3: (state) => { + state.count3++ + }, + }, + }) + + const store = configureStore({ + reducer: countersSlice.reducer, + }) + + const selector1 = jest.fn((s: StateType) => { + return s.count1 + }) + const selector2 = jest.fn((s: StateType) => { + return s.count2 + }) + const selector3 = jest.fn((s: StateType) => { + return s.count3 + }) + const renderedItems: number[] = [] + + let subscription: Subscription + + const Comp = () => { + subscription = useContext(ReactReduxContext).subscription + const c1 = useSelector(selector1) + const c2 = useSelector(selector2) + const c3 = useSelector(selector3) + + return null + } + + rtl.render( + + + + ) + + const listeners = subscription!.getListeners().get() + + expect(listeners.length).toBe(3) + + // Selector first called on Comp mount, and then re-invoked after mount due to useLayoutEffect dispatching event + expect(selector1).toHaveBeenCalledTimes(1) + expect(selector2).toHaveBeenCalledTimes(1) + expect(selector3).toHaveBeenCalledTimes(1) + + expect(listeners[0].selectorCache!.cache.needsRecalculation()).toBe( + false + ) + expect(listeners[1].selectorCache!.cache.needsRecalculation()).toBe( + false + ) + expect(listeners[2].selectorCache!.cache.needsRecalculation()).toBe( + false + ) + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment1()) + console.log('Dispatch complete') + + expect(selector1).toHaveBeenCalledTimes(2) + expect(selector2).toHaveBeenCalledTimes(1) + expect(selector3).toHaveBeenCalledTimes(1) + }) + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment2()) + + expect(selector1).toHaveBeenCalledTimes(2) + expect(selector2).toHaveBeenCalledTimes(2) + expect(selector3).toHaveBeenCalledTimes(1) + }) + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment3()) + + expect(selector1).toHaveBeenCalledTimes(2) + expect(selector2).toHaveBeenCalledTimes(2) + expect(selector3).toHaveBeenCalledTimes(2) + }) + }) }) it('uses the latest selector', () => { @@ -450,7 +566,10 @@ describe('React', () => { const [, f] = useReducer((c) => c + 1, 0) forceRender = f const renderedSelectorId = selectorId++ - const value = useSelector(() => renderedSelectorId) + const value = useSelector((state: NormalStateType) => { + const { count } = state + return renderedSelectorId + }) renderedItems.push(value) return
} @@ -492,6 +611,7 @@ describe('React', () => { } const Child = ({ parentCount }: ChildPropsType) => { const result = useNormalSelector(({ count }) => { + // console.log('Selector: ', { count, parentCount }) if (count !== parentCount) { throw new Error() } @@ -508,6 +628,7 @@ describe('React', () => { ) + // console.log('Running second dispatch') const doDispatch = () => normalStore.dispatch({ type: '' }) expect(doDispatch).not.toThrowError() @@ -518,22 +639,20 @@ describe('React', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) const Comp = () => { - const result = useSelector((count: number) => { - if (count > 0) { + const result = useSelector((state: NormalStateType) => { + if (state.count > 0) { // console.log('Throwing error') throw new Error('Panic!') } - return count + return state.count }) return
{result}
} - const store = createStore((count: number = -1): number => count + 1) - const App = () => ( - + ) @@ -544,7 +663,7 @@ describe('React', () => { // The test selector will happen to re-throw while rendering and we do see that. expect(() => { rtl.act(() => { - store.dispatch({ type: '' }) + normalStore.dispatch({ type: '' }) }) }).toThrow(/Panic!/) @@ -554,13 +673,19 @@ describe('React', () => { it('re-throws errors from the selector that only occur during rendering', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) + let forceParentRender: () => void const Parent = () => { - const count = useNormalSelector((s) => s.count) + const [, forceRender] = useReducer((c) => c + 1, 0) + forceParentRender = forceRender + const count = useNormalSelector((s) => { + return s.count + }) return } const Child = ({ parentCount }: ChildPropsType) => { const result = useNormalSelector(({ count }) => { + console.trace('Selector values: ', { count, parentCount }) if (parentCount > 0) { throw new Error() } @@ -580,6 +705,7 @@ describe('React', () => { expect(() => { rtl.act(() => { normalStore.dispatch({ type: '' }) + //forceParentRender() }) }).toThrowError() @@ -925,6 +1051,129 @@ describe('React', () => { }) }) }) + + describe('Auto-tracking behavior checks', () => { + interface Todo { + id: number + name: string + completed: boolean + } + + type TodosState = Todo[] + + const counterSlice = createSlice({ + name: 'counters', + initialState: { + deeply: { + nested: { + really: { + deeply: { + nested: { + c1: { value: 0 }, + }, + }, + }, + }, + }, + + c2: { value: 0 }, + }, + reducers: { + increment1(state) { + // state.c1.value++ + state.deeply.nested.really.deeply.nested.c1.value++ + }, + increment2(state) { + state.c2.value++ + }, + }, + }) + + const todosSlice = createSlice({ + name: 'todos', + initialState: [ + { id: 0, name: 'a', completed: false }, + { id: 1, name: 'b', completed: false }, + { id: 2, name: 'c', completed: false }, + ] as TodosState, + reducers: { + toggleCompleted(state, action: PayloadAction) { + const todo = state.find((todo) => todo.id === action.payload) + if (todo) { + todo.completed = !todo.completed + } + }, + setName(state) { + state[1].name = 'd' + }, + }, + }) + + function makeStore() { + return configureStore({ + reducer: { + counter: counterSlice.reducer, + todos: todosSlice.reducer, + }, + middleware: (gDM) => + gDM({ + serializableCheck: false, + immutableCheck: false, + }), + }) + } + + type AppStore = ReturnType + let store: AppStore + type RootState = ReturnType + + const useAppSelector: TypedUseSelectorHook = useSelector + + beforeEach(() => { + store = makeStore() + }) + + test.only('should correctly handle updates to nested data', async () => { + let itemSelectorCallsCount = 0 + let listSelectorCallsCount = 0 + function TodoListItem({ todoId }: { todoId: number }) { + console.log('TodoListItem render: ', todoId) + const todo = useAppSelector((state) => { + itemSelectorCallsCount++ + return state.todos.find((t) => t.id === todoId) + })! + return ( +
+ {todo.id}: {todo.name} ({todo.completed}) +
+ ) + } + + function TodoList() { + const todoIds = useAppSelector((state) => { + listSelectorCallsCount++ + return state.todos.map((t) => t.id) + }) + console.log('TodoList render: ', todoIds) + return ( + <> + {todoIds.map((id) => ( + + ))} + + ) + } + + rtl.render( + + + + ) + + expect(listSelectorCallsCount).toBe(1) + expect(itemSelectorCallsCount).toBe(3) + }) + }) }) describe('createSelectorHook', () => { diff --git a/test/integration/ssr.spec.tsx b/test/integration/ssr.spec.tsx index 42e7a2256..aaa73f4f5 100644 --- a/test/integration/ssr.spec.tsx +++ b/test/integration/ssr.spec.tsx @@ -13,7 +13,7 @@ import { const IS_REACT_18 = React.version.startsWith('18') -describe('New v8 serverState behavior', () => { +describe.skip('New v8 serverState behavior', () => { interface State { count: number data: string[]