From 6d3acbeedfa4ddc14153e81a66b281ceede229ee Mon Sep 17 00:00:00 2001 From: wurstbonbon <3285994+wurstbonbon@users.noreply.github.com> Date: Sun, 8 Aug 2021 20:50:01 +0200 Subject: [PATCH] Mirgate to useMutableSource [first draft] --- package.json | 10 +- src/alternate-renderers.ts | 9 - src/components/Context.ts | 5 +- src/components/Provider.tsx | 44 +- src/components/connect.tsx | 354 +---- src/exports.ts | 2 - src/hooks/useSelector.ts | 122 +- src/index.ts | 9 - src/utils/Subscription.ts | 151 -- src/utils/batch.ts | 13 - src/utils/reactBatchedUpdates.native.ts | 5 - src/utils/reactBatchedUpdates.ts | 1 - src/utils/useIsomorphicLayoutEffect.native.ts | 5 - src/utils/useIsomorphicLayoutEffect.ts | 17 - src/utils/useStoreSource.ts | 16 + test/components/Provider.spec.tsx | 19 +- test/components/connect.spec.tsx | 201 ++- test/hooks/useReduxContext.spec.tsx | 2 +- test/hooks/useSelector.spec.tsx | 78 +- test/react-native/batch-integration.tsx | 606 -------- test/utils/Subscription.spec.ts | 59 - tsconfig.json | 3 +- yarn.lock | 1363 ++++++++++------- 23 files changed, 1126 insertions(+), 1968 deletions(-) delete mode 100644 src/alternate-renderers.ts delete mode 100644 src/utils/Subscription.ts delete mode 100644 src/utils/batch.ts delete mode 100644 src/utils/reactBatchedUpdates.native.ts delete mode 100644 src/utils/reactBatchedUpdates.ts delete mode 100644 src/utils/useIsomorphicLayoutEffect.native.ts delete mode 100644 src/utils/useIsomorphicLayoutEffect.ts create mode 100644 src/utils/useStoreSource.ts delete mode 100644 test/react-native/batch-integration.tsx delete mode 100644 test/utils/Subscription.spec.ts diff --git a/package.json b/package.json index de228a83c..3abf7ecc6 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,8 @@ "@rollup/plugin-replace": "^2.3.3", "@testing-library/jest-dom": "^5.11.5", "@testing-library/jest-native": "^3.4.3", - "@testing-library/react": "^12.0.0", - "@testing-library/react-hooks": "^3.4.2", + "@testing-library/react": "file:///tmp/react.tgz", + "@testing-library/react-hooks": "^7.0.1", "@testing-library/react-native": "^7.1.0", "@types/create-react-class": "^15.6.3", "@types/object-assign": "^4.0.30", @@ -103,10 +103,10 @@ "glob": "^7.1.6", "jest": "^26.6.1", "prettier": "^2.1.2", - "react": "^16.14.0", - "react-dom": "^16.14.0", + "react": "18.0.0-alpha-b9934d6db-20210805", + "react-dom": "18.0.0-alpha-b9934d6db-20210805", "react-native": "^0.64.1", - "react-test-renderer": "^16.14.0", + "react-test-renderer": "18.0.0-alpha-b9934d6db-20210805", "redux": "^4.0.5", "rimraf": "^3.0.2", "rollup": "^2.32.1", diff --git a/src/alternate-renderers.ts b/src/alternate-renderers.ts deleted file mode 100644 index 456c7a23d..000000000 --- a/src/alternate-renderers.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './exports' - -import { getBatch } from './utils/batch' - -// For other renderers besides ReactDOM and React Native, -// use the default noop batch function -const batch = getBatch() - -export { batch } diff --git a/src/components/Context.ts b/src/components/Context.ts index 1dbf9568a..66729d26e 100644 --- a/src/components/Context.ts +++ b/src/components/Context.ts @@ -1,14 +1,13 @@ -import React from 'react' +import React, { MutableSource } from 'react' import { Action, AnyAction, Store } from 'redux' import type { FixTypeLater } from '../types' -import type { Subscription } from '../utils/Subscription' export interface ReactReduxContextValue< SS = FixTypeLater, A extends Action = AnyAction > { + storeSource: MutableSource> store: Store - subscription: Subscription } export const ReactReduxContext = diff --git a/src/components/Provider.tsx b/src/components/Provider.tsx index cb22b55bf..adfe25135 100644 --- a/src/components/Provider.tsx +++ b/src/components/Provider.tsx @@ -1,7 +1,10 @@ -import React, { Context, ReactNode, useMemo } from 'react' +import React, { + Context, + ReactNode, + unstable_createMutableSource as createMutableSource, + useMemo, +} from 'react' import { ReactReduxContext, ReactReduxContextValue } from './Context' -import { createSubscription } from '../utils/Subscription' -import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import type { FixTypeLater } from '../types' import { Action, AnyAction, Store } from 'redux' @@ -19,31 +22,18 @@ export interface ProviderProps { children: ReactNode } -function Provider({ store, context, children }: ProviderProps) { - const contextValue = useMemo(() => { - const subscription = createSubscription(store) - subscription.onStateChange = subscription.notifyNestedSubs - return { - store, - subscription, - } - }, [store]) - - const previousState = useMemo(() => store.getState(), [store]) - - useIsomorphicLayoutEffect(() => { - const { subscription } = contextValue - subscription.trySubscribe() - - if (previousState !== store.getState()) { - subscription.notifyNestedSubs() - } - return () => { - subscription.tryUnsubscribe() - subscription.onStateChange = undefined - } - }, [contextValue, previousState]) +export function createReduxContext(store: Store) { + return { + storeSource: createMutableSource(store, () => store.getState()), + store, + } +} +function Provider({ store, context, children }: ProviderProps) { + const contextValue: ReactReduxContextValue = useMemo( + () => createReduxContext(store), + [store] + ) const Context = context || ReactReduxContext return {children} diff --git a/src/components/connect.tsx b/src/components/connect.tsx index d018fc0ed..423601cd3 100644 --- a/src/components/connect.tsx +++ b/src/components/connect.tsx @@ -1,8 +1,8 @@ /* eslint-disable valid-jsdoc, @typescript-eslint/no-unused-vars */ import hoistStatics from 'hoist-non-react-statics' -import React, { useContext, useMemo, useRef, useReducer } from 'react' -import { isValidElementType, isContextConsumer } from 'react-is' -import type { Store, Dispatch, Action, AnyAction } from 'redux' +import React, { useCallback, useContext, useMemo } from 'react' +import { isContextConsumer, isValidElementType } from 'react-is' +import type { Dispatch, Store } from 'redux' import type { AdvancedComponentDecorator, @@ -25,8 +25,6 @@ import defaultMapDispatchToPropsFactories from '../connect/mapDispatchToProps' import defaultMapStateToPropsFactories from '../connect/mapStateToProps' import defaultMergePropsFactories from '../connect/mergeProps' -import { createSubscription, Subscription } from '../utils/Subscription' -import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import shallowEqual from '../utils/shallowEqual' import { @@ -34,6 +32,8 @@ import { ReactReduxContextValue, ReactReduxContextInstance, } from './Context' +import { createReduxContext } from './Provider' +import { useStoreSource } from '../utils/useStoreSource' // Define some constant arrays just to avoid re-creating these const EMPTY_ARRAY: [unknown, number] = [null, 0] @@ -62,143 +62,6 @@ function storeStateUpdatesReducer( return [action.payload, updateCount + 1] } -type EffectFunc = (...args: any[]) => void | ReturnType - -// This is "just" a `useLayoutEffect`, but with two modifications: -// - we need to fall back to `useEffect` in SSR to avoid annoying warnings -// - we extract this to a separate function to avoid closing over values -// and causing memory leaks -function useIsomorphicLayoutEffectWithArgs( - effectFunc: EffectFunc, - effectArgs: any[], - dependencies?: React.DependencyList -) { - useIsomorphicLayoutEffect(() => effectFunc(...effectArgs), dependencies) -} - -// Effect callback, extracted: assign the latest props values to refs for later usage -function captureWrapperProps( - lastWrapperProps: React.MutableRefObject, - lastChildProps: React.MutableRefObject, - renderIsScheduled: React.MutableRefObject, - wrapperProps: unknown, - actualChildProps: unknown, - childPropsFromStoreUpdate: React.MutableRefObject, - notifyNestedSubs: () => void -) { - // We want to capture the wrapper props and child props we used for later comparisons - lastWrapperProps.current = wrapperProps - lastChildProps.current = actualChildProps - renderIsScheduled.current = false - - // If the render was from a store update, clear out that reference and cascade the subscriber update - if (childPropsFromStoreUpdate.current) { - childPropsFromStoreUpdate.current = null - notifyNestedSubs() - } -} - -// Effect callback, extracted: subscribe to the Redux store or nearest connected ancestor, -// check for updates after dispatched actions, and trigger re-renders. -function subscribeUpdates( - shouldHandleStateChanges: boolean, - store: Store, - subscription: Subscription, - childPropsSelector: (state: unknown, props: unknown) => unknown, - lastWrapperProps: React.MutableRefObject, - lastChildProps: React.MutableRefObject, - renderIsScheduled: React.MutableRefObject, - childPropsFromStoreUpdate: React.MutableRefObject, - notifyNestedSubs: () => void, - forceComponentUpdateDispatch: React.Dispatch -) { - // If we're not subscribed to the store, nothing to do here - if (!shouldHandleStateChanges) return - - // Capture values for checking if and when this component unmounts - let didUnsubscribe = false - let lastThrownError: Error | null = null - - // We'll run this callback every time a store subscription update propagates to this component - const checkForUpdates = () => { - if (didUnsubscribe) { - // Don't run stale listeners. - // Redux doesn't guarantee unsubscriptions happen until next dispatch. - return - } - - const latestStoreState = store.getState() - - let newChildProps, error - try { - // Actually run the selector with the most recent store state and wrapper props - // to determine what the child props should be - newChildProps = childPropsSelector( - latestStoreState, - lastWrapperProps.current - ) - } catch (e) { - error = e - lastThrownError = e as Error | null - } - - if (!error) { - lastThrownError = null - } - - // If the child props haven't changed, nothing to do here - cascade the subscription update - if (newChildProps === lastChildProps.current) { - if (!renderIsScheduled.current) { - notifyNestedSubs() - } - } else { - // Save references to the new child props. Note that we track the "child props from store update" - // as a ref instead of a useState/useReducer because we need a way to determine if that value has - // been processed. If this went into useState/useReducer, we couldn't clear out the value without - // forcing another re-render, which we don't want. - lastChildProps.current = newChildProps - childPropsFromStoreUpdate.current = newChildProps - renderIsScheduled.current = true - - // If the child props _did_ change (or we caught an error), this wrapper component needs to re-render - forceComponentUpdateDispatch({ - type: 'STORE_UPDATED', - payload: { - error, - }, - }) - } - } - - // Actually subscribe to the nearest connected ancestor (or store) - subscription.onStateChange = checkForUpdates - subscription.trySubscribe() - - // Pull data from the store after first render in case the store has - // changed since we began. - checkForUpdates() - - const unsubscribeWrapper = () => { - didUnsubscribe = true - subscription.tryUnsubscribe() - subscription.onStateChange = null - - if (lastThrownError) { - // It's possible that we caught an error due to a bad mapState function, but the - // parent re-rendered without this component and we're about to unmount. - // This shouldn't happen as long as we do top-down subscriptions correctly, but - // if we ever do those wrong, this throw will surface the error in our tests. - // In that case, throw the error from here so it doesn't get lost. - throw lastThrownError - } - } - - return unsubscribeWrapper -} - -// Reducer initial state creation for our update reducer -const initStateUpdates = () => EMPTY_ARRAY - export interface ConnectProps { reactReduxForwardedRef?: React.ForwardedRef context?: ReactReduxContextInstance @@ -501,7 +364,7 @@ function connect< const wrapWithConnect: AdvancedComponentDecorator< TOwnProps, WrappedComponentProps - > = (WrappedComponent) => { + > = ((WrappedComponent) => { if ( process.env.NODE_ENV !== 'production' && !isValidElementType(WrappedComponent) @@ -534,14 +397,42 @@ function connect< areMergedPropsEqual, } - // If we aren't running in "pure" mode, we don't want to memoize values. - // To avoid conditionally calling hooks, we fall back to a tiny wrapper - // that just executes the given callback immediately. - const usePureOnlyMemo = pure - ? useMemo - : (callback: () => void) => callback() + const useChildPropsSelector = (context: ReactReduxContextValue) => { + return useMemo( + () => + defaultSelectorFactory( + context.store.dispatch, + selectorFactoryOptions + ), + [context.store] + ) + } + + const useStateAndSubscribe = ( + context: ReactReduxContextValue, + wrapperProps: Omit + ) => { + const childPropsSelector = useChildPropsSelector(context) + const getSnapshot = useCallback( + (store) => childPropsSelector(store.getState(), wrapperProps), + [childPropsSelector, wrapperProps] + ) + return useStoreSource(context!.storeSource, getSnapshot) + } + + const useDispatchOnly = ( + context: ReactReduxContextValue, + wrapperProps: Omit + ) => { + const childPropsSelector = useChildPropsSelector(context) + return childPropsSelector(context.store.getState(), wrapperProps) + } + + const useReduxContext = shouldHandleStateChanges + ? useStateAndSubscribe + : useDispatchOnly - function ConnectFunction(props: ConnectProps & TOwnProps) { + function ConnectFunction(props: ConnectProps & TOwnProps) { const [propsContext, reactReduxForwardedRef, wrapperProps] = useMemo(() => { // Distinguish between actual "data" props that were passed to the wrapper component, @@ -573,7 +464,9 @@ function connect< Boolean(props.store!.getState) && Boolean(props.store!.dispatch) const didStoreComeFromContext = - Boolean(contextValue) && Boolean(contextValue!.store) + Boolean(contextValue) && + Boolean(contextValue!.storeSource) && + Boolean(contextValue!.store) if ( process.env.NODE_ENV !== 'production' && @@ -588,159 +481,24 @@ function connect< ) } - // Based on the previous check, one of these must be true - const store: Store = didStoreComeFromProps - ? props.store! - : contextValue!.store - - const childPropsSelector = useMemo(() => { - // The child props selector needs the store reference as an input. - // Re-create this selector whenever the store changes. - return defaultSelectorFactory(store.dispatch, selectorFactoryOptions) - }, [store]) - - const [subscription, notifyNestedSubs] = useMemo(() => { - if (!shouldHandleStateChanges) return NO_SUBSCRIPTION_ARRAY - - // This Subscription's source should match where store came from: props vs. context. A component - // connected to the store via props shouldn't use subscription from context, or vice versa. - const subscription = createSubscription( - store, - didStoreComeFromProps ? undefined : contextValue!.subscription - ) + const reduxContextValue: ReactReduxContextValue = useMemo(() => { + // Based on the previous check, one of these must be true + return didStoreComeFromProps + ? createReduxContext(props.store!) + : contextValue! + }, [didStoreComeFromProps, props.store, contextValue]) - // `notifyNestedSubs` is duplicated to handle the case where the component is unmounted in - // the middle of the notification loop, where `subscription` will then be null. This can - // probably be avoided if Subscription's listeners logic is changed to not call listeners - // that have been unsubscribed in the middle of the notification loop. - const notifyNestedSubs = - subscription.notifyNestedSubs.bind(subscription) - - return [subscription, notifyNestedSubs] - }, [store, didStoreComeFromProps, contextValue]) - - // Determine what {store, subscription} value should be put into nested context, if necessary, - // and memoize that value to avoid unnecessary context updates. - const overriddenContextValue = useMemo(() => { - if (didStoreComeFromProps) { - // This component is directly subscribed to a store from props. - // We don't want descendants reading from this store - pass down whatever - // the existing context value is from the nearest connected ancestor. - return contextValue! - } - - // Otherwise, put this component's subscription instance into context, so that - // connected descendants won't update until after this component is done - return { - ...contextValue, - subscription, - } as ReactReduxContextValue - }, [didStoreComeFromProps, contextValue, subscription]) - - // We need to force this wrapper component to re-render whenever a Redux store update - // causes a change to the calculated child component props (or we caught an error in mapState) - const [[previousStateUpdateResult], forceComponentUpdateDispatch] = - useReducer( - storeStateUpdatesReducer, - // @ts-ignore - EMPTY_ARRAY as any, - initStateUpdates - ) - - // Propagate any mapState/mapDispatch errors upwards - if (previousStateUpdateResult && previousStateUpdateResult.error) { - throw previousStateUpdateResult.error - } - - // Set up refs to coordinate values between the subscription effect and the render logic - const lastChildProps = useRef() - const lastWrapperProps = useRef(wrapperProps) - const childPropsFromStoreUpdate = useRef() - const renderIsScheduled = useRef(false) - - const actualChildProps = usePureOnlyMemo(() => { - // Tricky logic here: - // - This render may have been triggered by a Redux store update that produced new child props - // - However, we may have gotten new wrapper props after that - // If we have new child props, and the same wrapper props, we know we should use the new child props as-is. - // But, if we have new wrapper props, those might change the child props, so we have to recalculate things. - // So, we'll use the child props from store update only if the wrapper props are the same as last time. - if ( - childPropsFromStoreUpdate.current && - wrapperProps === lastWrapperProps.current - ) { - return childPropsFromStoreUpdate.current - } - - // TODO We're reading the store directly in render() here. Bad idea? - // This will likely cause Bad Things (TM) to happen in Concurrent Mode. - // Note that we do this because on renders _not_ caused by store updates, we need the latest store state - // to determine what the child props should be. - return childPropsSelector(store.getState(), wrapperProps) - }, [store, previousStateUpdateResult, wrapperProps]) - - // We need this to execute synchronously every time we re-render. However, React warns - // about useLayoutEffect in SSR, so we try to detect environment and fall back to - // just useEffect instead to avoid the warning, since neither will run anyway. - useIsomorphicLayoutEffectWithArgs(captureWrapperProps, [ - lastWrapperProps, - lastChildProps, - renderIsScheduled, - wrapperProps, - actualChildProps, - childPropsFromStoreUpdate, - notifyNestedSubs, - ]) - - // Our re-subscribe logic only runs when the store/subscription setup changes - useIsomorphicLayoutEffectWithArgs( - subscribeUpdates, - [ - shouldHandleStateChanges, - store, - subscription, - childPropsSelector, - lastWrapperProps, - lastChildProps, - renderIsScheduled, - childPropsFromStoreUpdate, - notifyNestedSubs, - forceComponentUpdateDispatch, - ], - [store, subscription, childPropsSelector] - ) + const childProps = useReduxContext(reduxContextValue, wrapperProps) // Now that all that's done, we can finally try to actually render the child component. // We memoize the elements for the rendered child component as an optimization. - const renderedWrappedComponent = useMemo( + return useMemo( () => ( // @ts-ignore - + ), - [reactReduxForwardedRef, WrappedComponent, actualChildProps] + [reactReduxForwardedRef, WrappedComponent, childProps] ) - - // If React sees the exact same element reference as last time, it bails out of re-rendering - // that child, same as if it was wrapped in React.memo() or returned false from shouldComponentUpdate. - const renderedChild = useMemo(() => { - if (shouldHandleStateChanges) { - // If this component is subscribed to store updates, we need to pass its own - // subscription instance down to our descendants. That means rendering the same - // Context instance, and putting a different value into the context. - return ( - - {renderedWrappedComponent} - - ) - } - - return renderedWrappedComponent - }, [ContextToUse, renderedWrappedComponent, overriddenContextValue]) - - return renderedChild } // If we're in "pure" mode, ensure our wrapper component only re-renders when incoming props have changed. @@ -766,14 +524,14 @@ function connect< return }) - const forwarded = _forwarded as ConnectedWrapperComponent + const forwarded = _forwarded as unknown as ConnectedWrapperComponent forwarded.displayName = displayName forwarded.WrappedComponent = WrappedComponent return hoistStatics(forwarded, WrappedComponent) } return hoistStatics(Connect, WrappedComponent) - } + }) as AdvancedComponentDecorator return wrapWithConnect } diff --git a/src/exports.ts b/src/exports.ts index 94fd09b03..21deac22e 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -22,7 +22,6 @@ import { useSelector, createSelectorHook } from './hooks/useSelector' import { useStore, createStoreHook } from './hooks/useStore' import shallowEqual from './utils/shallowEqual' -import type { Subscription } from '../src/utils/Subscription' export * from './types' export type { @@ -41,7 +40,6 @@ export type { MapDispatchToPropsNonObject, MergeProps, ReactReduxContextValue, - Subscription, } export { Provider, diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index ee072d9f1..bbc0b3bb1 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -1,106 +1,11 @@ -import { useReducer, useRef, useMemo, useContext, useDebugValue } from 'react' +import { useCallback, useContext, useDebugValue, useRef } from 'react' import { useReduxContext as useDefaultReduxContext } from './useReduxContext' -import { createSubscription, Subscription } from '../utils/Subscription' -import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' import { ReactReduxContext } from '../components/Context' -import { AnyAction, Store } from 'redux' import { DefaultRootState, EqualityFn } from '../types' +import { useStoreSource } from '../utils/useStoreSource' const refEquality: EqualityFn = (a, b) => a === b -type TSelector = (state: S) => R - -function useSelectorWithStoreAndSubscription( - selector: TSelector, - equalityFn: EqualityFn, - store: Store, - contextSub: Subscription -): TSelectedState { - const [, forceRender] = useReducer((s) => s + 1, 0) - - const subscription = useMemo( - () => createSubscription(store, contextSub), - [store, contextSub] - ) - - const latestSubscriptionCallbackError = useRef() - const latestSelector = useRef>() - const latestStoreState = useRef() - const latestSelectedState = useRef() - - const storeState = store.getState() - let selectedState: TSelectedState | undefined - - try { - if ( - selector !== latestSelector.current || - storeState !== latestStoreState.current || - latestSubscriptionCallbackError.current - ) { - const newSelectedState = selector(storeState) - // ensure latest selected state is reused so that a custom equality function can result in identical references - if ( - latestSelectedState.current === undefined || - !equalityFn(newSelectedState, latestSelectedState.current) - ) { - selectedState = newSelectedState - } else { - selectedState = latestSelectedState.current - } - } else { - selectedState = latestSelectedState.current - } - } catch (err) { - if (latestSubscriptionCallbackError.current) { - ;( - err as Error - ).message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n` - } - - throw err - } - - useIsomorphicLayoutEffect(() => { - latestSelector.current = selector - latestStoreState.current = storeState - latestSelectedState.current = selectedState - latestSubscriptionCallbackError.current = undefined - }) - - useIsomorphicLayoutEffect(() => { - function checkForUpdates() { - try { - const newStoreState = store.getState() - const newSelectedState = latestSelector.current!(newStoreState) - - if (equalityFn(newSelectedState, latestSelectedState.current)) { - return - } - - latestSelectedState.current = newSelectedState - latestStoreState.current = newStoreState - } catch (err) { - // we ignore all errors here, since when the component - // is re-rendered, the selectors are called again, and - // will throw again, if neither props nor store state - // changed - latestSubscriptionCallbackError.current = err as Error - } - - forceRender() - } - - subscription.onStateChange = checkForUpdates - subscription.trySubscribe() - - checkForUpdates() - - return () => subscription.tryUnsubscribe() - }, [store, subscription]) - - return selectedState! -} - /** * Hook factory, which creates a `useSelector` hook bound to a given context. * @@ -135,15 +40,24 @@ export function createSelectorHook( ) } } - const { store, subscription: contextSub } = useReduxContext()! - - const selectedState = useSelectorWithStoreAndSubscription( - selector, - equalityFn, - store, - contextSub + const { storeSource } = useReduxContext()! + + const lastValue = useRef() + const getSnapshot = useCallback( + (store): Selected => { + const value = selector(store.getState()) + if ( + lastValue.current === undefined || + !equalityFn(value, lastValue.current) + ) { + lastValue.current = value + } + return lastValue.current + }, + [selector, equalityFn] ) + const selectedState = useStoreSource(storeSource, getSnapshot) useDebugValue(selectedState) return selectedState diff --git a/src/index.ts b/src/index.ts index f37b19b31..b44737ae2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1 @@ export * from './exports' - -import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates' -import { setBatch } from './utils/batch' - -// Enable batched updates in our subscriptions for use -// with standard React renderers (ReactDOM, React Native) -setBatch(batch) - -export { batch } diff --git a/src/utils/Subscription.ts b/src/utils/Subscription.ts deleted file mode 100644 index 0ea00c4fb..000000000 --- a/src/utils/Subscription.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { getBatch } from './batch' - -// 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 -// ancestor components re-render before descendants - -type VoidFunc = () => void - -type Listener = { - callback: VoidFunc - next: Listener | null - prev: Listener | null -} - -function createListenerCollection() { - const batch = getBatch() - let first: Listener | null = null - let last: Listener | null = null - - return { - clear() { - first = null - last = null - }, - - notify() { - batch(() => { - let listener = first - while (listener) { - listener.callback() - listener = listener.next - } - }) - }, - - get() { - let listeners = [] - let listener = first - while (listener) { - listeners.push(listener) - listener = listener.next - } - return listeners - }, - - subscribe(callback: () => void) { - let isSubscribed = true - - let listener: Listener = (last = { - callback, - next: null, - prev: last, - }) - - if (listener.prev) { - listener.prev.next = listener - } else { - first = listener - } - - return function unsubscribe() { - if (!isSubscribed || first === null) return - isSubscribed = false - - if (listener.next) { - listener.next.prev = listener.prev - } else { - last = listener.prev - } - if (listener.prev) { - listener.prev.next = listener.next - } else { - first = listener.next - } - } - }, - } -} - -type ListenerCollection = ReturnType - -export interface Subscription { - addNestedSub: (listener: VoidFunc) => VoidFunc - notifyNestedSubs: VoidFunc - handleChangeWrapper: VoidFunc - isSubscribed: () => boolean - onStateChange?: VoidFunc | null - trySubscribe: VoidFunc - tryUnsubscribe: VoidFunc - getListeners: () => ListenerCollection -} - -const nullListeners = { - notify() {}, - get: () => [], -} as unknown as ListenerCollection - -export function createSubscription(store: any, parentSub?: Subscription) { - let unsubscribe: VoidFunc | undefined - let listeners: ListenerCollection = nullListeners - - function addNestedSub(listener: () => void) { - trySubscribe() - return listeners.subscribe(listener) - } - - function notifyNestedSubs() { - listeners.notify() - } - - function handleChangeWrapper() { - if (subscription.onStateChange) { - subscription.onStateChange() - } - } - - function isSubscribed() { - return Boolean(unsubscribe) - } - - function trySubscribe() { - if (!unsubscribe) { - unsubscribe = parentSub - ? parentSub.addNestedSub(handleChangeWrapper) - : store.subscribe(handleChangeWrapper) - - listeners = createListenerCollection() - } - } - - function tryUnsubscribe() { - if (unsubscribe) { - unsubscribe() - unsubscribe = undefined - listeners.clear() - listeners = nullListeners - } - } - - const subscription: Subscription = { - addNestedSub, - notifyNestedSubs, - handleChangeWrapper, - isSubscribed, - trySubscribe, - tryUnsubscribe, - getListeners: () => listeners, - } - - return subscription -} diff --git a/src/utils/batch.ts b/src/utils/batch.ts deleted file mode 100644 index 2d116eae0..000000000 --- a/src/utils/batch.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Default to a dummy "batch" implementation that just runs the callback -function defaultNoopBatch(callback: () => void) { - callback() -} - -let batch = defaultNoopBatch - -// Allow injecting another batching function later -export const setBatch = (newBatch: typeof defaultNoopBatch) => - (batch = newBatch) - -// Supply a getter just to skip dealing with ESM bindings -export const getBatch = () => batch diff --git a/src/utils/reactBatchedUpdates.native.ts b/src/utils/reactBatchedUpdates.native.ts deleted file mode 100644 index a92cd6768..000000000 --- a/src/utils/reactBatchedUpdates.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* eslint-disable import/namespace */ -/* eslint-disable import/named */ -import { unstable_batchedUpdates } from 'react-native' - -export { unstable_batchedUpdates } diff --git a/src/utils/reactBatchedUpdates.ts b/src/utils/reactBatchedUpdates.ts deleted file mode 100644 index 0fca6d85e..000000000 --- a/src/utils/reactBatchedUpdates.ts +++ /dev/null @@ -1 +0,0 @@ -export { unstable_batchedUpdates } from 'react-dom' diff --git a/src/utils/useIsomorphicLayoutEffect.native.ts b/src/utils/useIsomorphicLayoutEffect.native.ts deleted file mode 100644 index e80393ad9..000000000 --- a/src/utils/useIsomorphicLayoutEffect.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useLayoutEffect } from 'react' - -// Under React Native, we know that we always want to use useLayoutEffect - -export const useIsomorphicLayoutEffect = useLayoutEffect diff --git a/src/utils/useIsomorphicLayoutEffect.ts b/src/utils/useIsomorphicLayoutEffect.ts deleted file mode 100644 index 0e87d6e0c..000000000 --- a/src/utils/useIsomorphicLayoutEffect.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect, useLayoutEffect } from 'react' - -// React currently throws a warning when using useLayoutEffect on the server. -// To get around it, we can conditionally useEffect on the server (no-op) and -// useLayoutEffect in the browser. We need useLayoutEffect to ensure the store -// subscription callback always has the selector from the latest render commit -// available, otherwise a store update may happen between render and the effect, -// which may cause missed updates; we also must ensure the store subscription -// is created synchronously, otherwise a store update may occur before the -// subscription is created and an inconsistent state may be observed - -export const useIsomorphicLayoutEffect = - typeof window !== 'undefined' && - typeof window.document !== 'undefined' && - typeof window.document.createElement !== 'undefined' - ? useLayoutEffect - : useEffect diff --git a/src/utils/useStoreSource.ts b/src/utils/useStoreSource.ts new file mode 100644 index 000000000..43b5fd16a --- /dev/null +++ b/src/utils/useStoreSource.ts @@ -0,0 +1,16 @@ +import { Store } from 'redux' +import { + MutableSource, + unstable_useMutableSource as useMutableSource, +} from 'react' + +const subscribe = (store: Store, callback: () => void) => { + return store.subscribe(callback) +} + +export const useStoreSource = ( + source: MutableSource, + getSnapshot: (store: Store) => Value +): Value => { + return useMutableSource(source, getSnapshot, subscribe) +} diff --git a/test/components/Provider.spec.tsx b/test/components/Provider.spec.tsx index f482ef0b4..492d10d6d 100644 --- a/test/components/Provider.spec.tsx +++ b/test/components/Provider.spec.tsx @@ -342,16 +342,19 @@ describe('React', () => { } } - const div = document.createElement('div') - ReactDOM.render( - -
- , - div - ) + const root = ReactDOM.createRoot(document.createElement('div')) + rtl.act(() => { + root.render( + +
+ + ) + }) expect(spy).toHaveBeenCalledTimes(0) - ReactDOM.unmountComponentAtNode(div) + rtl.act(() => { + root.unmount() + }) expect(spy).toHaveBeenCalledTimes(1) }) diff --git a/test/components/connect.spec.tsx b/test/components/connect.spec.tsx index 5b97021f9..64379da1a 100644 --- a/test/components/connect.spec.tsx +++ b/test/components/connect.spec.tsx @@ -402,7 +402,9 @@ describe('React', () => { expect(tester.getByTestId('x')).toHaveTextContent('true') props = {} - container.current!.forceUpdate() + rtl.act(() => { + container.current!.forceUpdate() + }) expect(tester.queryByTestId('x')).toBe(null) }) @@ -440,7 +442,9 @@ describe('React', () => { expect(tester.getByTestId('x')).toHaveTextContent('true') props = {} - container.current!.forceUpdate() + rtl.act(() => { + container.current!.forceUpdate() + }) expect(tester.getAllByTitle('prop').length).toBe(1) expect(tester.getByTestId('dispatch')).toHaveTextContent( @@ -842,8 +846,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('DID') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('DID') + }) expect(invocationCount).toEqual(1) }) @@ -888,8 +896,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('DID') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('DID') + }) expect(invocationCount).toEqual(3) }) @@ -937,8 +949,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('BAZ') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('BAZ') + }) expect(invocationCount).toEqual(3) expect(propsPassedIn).toEqual({ @@ -988,8 +1004,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('DID') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('DID') + }) expect(invocationCount).toEqual(1) }) @@ -1034,9 +1054,12 @@ describe('React', () => { ) - - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('DID') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('DID') + }) expect(invocationCount).toEqual(3) }) @@ -1084,8 +1107,12 @@ describe('React', () => { ) - outerComponent.current!.setFoo('BAR') - outerComponent.current!.setFoo('BAZ') + rtl.act(() => { + outerComponent.current!.setFoo('BAR') + }) + rtl.act(() => { + outerComponent.current!.setFoo('BAZ') + }) expect(invocationCount).toEqual(3) expect(propsPassedIn).toEqual({ @@ -1160,20 +1187,23 @@ describe('React', () => { string >((state) => ({ state }))(Child) - const div = document.createElement('div') - ReactDOM.render( - - - , - div - ) + const root = ReactDOM.createRoot(document.createElement('div')) + rtl.act(() => { + root.render( + + + + ) + }) try { rtl.act(() => { store.dispatch({ type: 'APPEND', body: 'A' }) }) } finally { - ReactDOM.unmountComponentAtNode(div) + rtl.act(() => { + root.unmount() + }) } }) @@ -1254,17 +1284,28 @@ describe('React', () => { const div = document.createElement('div') document.body.appendChild(div) - ReactDOM.render( - - - , - div - ) + + const root = ReactDOM.createRoot(div) + rtl.act(() => { + root.render( + + + + ) + }) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) - linkA.current!.click() - linkB.current!.click() - linkB.current!.click() + rtl.act(() => { + linkA.current!.click() + }) + rtl.act(() => { + linkB.current!.click() + }) + rtl.act(() => { + linkB.current!.click()}) + rtl.act(() => { + root.unmount() + }) document.body.removeChild(div) // Called 3 times: @@ -1297,17 +1338,20 @@ describe('React', () => { (state) => ({ calls: mapStateToPropsCalls++ }), (dispatch) => ({ dispatch }) )(Container) - const div = document.createElement('div') - ReactDOM.render( - - - , - div - ) + const root = ReactDOM.createRoot(document.createElement('div')) + rtl.act(() => { + root.render( + + + + ) + }) expect(mapStateToPropsCalls).toBe(1) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) - ReactDOM.unmountComponentAtNode(div) + rtl.act(() => { + root.unmount() + }) expect(spy).toHaveBeenCalledTimes(0) expect(mapStateToPropsCalls).toBe(1) spy.mockRestore() @@ -1327,20 +1371,21 @@ describe('React', () => { (dispatch) => ({ dispatch }) )(Inner) - const div = document.createElement('div') - store.subscribe(() => { - ReactDOM.unmountComponentAtNode(div) - }) - + const root = ReactDOM.createRoot(document.createElement('div')) rtl.act(() => { - ReactDOM.render( + root.render( - , - div + ) }) + store.subscribe(() => { + rtl.act(() => { + root.unmount() + }); + }) + expect(mapStateToPropsCalls).toBe(1) const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) rtl.act(() => { @@ -1405,15 +1450,18 @@ describe('React', () => { store.dispatch({ type: 'fetch' }) }) - const div = document.createElement('div') - ReactDOM.render( - - - , - div - ) + const root = ReactDOM.createRoot(document.createElement('div')) + rtl.act(() => { + root.render( + + + + ) + }) - ReactDOM.unmountComponentAtNode(div) + rtl.act(() => { + root.unmount() + }) }) }) @@ -2101,9 +2149,16 @@ describe('React', () => { ) expect(mapStateToProps).toHaveBeenCalledTimes(0) - store.dispatch({ type: 'INC' }) + rtl.act(() => { + store.dispatch({ type: 'INC' }) + }) expect(mapStateToProps).toHaveBeenCalledTimes(1) - store.dispatch({ type: 'INC' }) + rtl.act(() => { + store.dispatch({ type: 'INC' }) + }) + rtl.act(() => { + store.dispatch({ type: 'INC' }) + }) expect(mapStateToProps).toHaveBeenCalledTimes(1) }) }) @@ -2682,8 +2737,10 @@ describe('React', () => { ) expect(tester.getByTestId('statefulValue')).toHaveTextContent('0') - //@ts-ignore - externalSetState({ value: 1 }) + rtl.act(() => { + //@ts-ignore + externalSetState({ value: 1 }) + }) expect(tester.getByTestId('statefulValue')).toHaveTextContent('1') }) @@ -2731,12 +2788,12 @@ describe('React', () => { ) const Decorated = decorator(ImpureComponent) - let externalSetState - let storeGetter = { storeKey: 'foo' } type StatefulWrapperStateType = { storeGetter: typeof storeGetter } type StatefulWrapperPropsType = {} + let storeGetter = { storeKey: 'foo' } + let externalSetState: Dispatch class StatefulWrapper extends Component< StatefulWrapperPropsType, StatefulWrapperStateType @@ -2772,8 +2829,10 @@ describe('React', () => { // Impure update storeGetter.storeKey = 'bar' - //@ts-ignore - externalSetState({ storeGetter }) + rtl.act(() => { + //@ts-ignore + externalSetState({ storeGetter }) + }) // 4) After the the impure update expect(mapStateSpy).toHaveBeenCalledTimes(3) @@ -3304,8 +3363,12 @@ describe('React', () => { ) - outerComponent.current!.setState(({ count }) => ({ count: count + 1 })) - store.dispatch({ type: '' }) + rtl.act(() => { + outerComponent.current!.setState(({ count }) => ({ count: count + 1 })) + }) + rtl.act(() => { + store.dispatch({ type: '' }) + }) //@ts-ignore expect(propsPassedIn.count).toEqual(1) //@ts-ignore @@ -3471,8 +3534,10 @@ describe('React', () => { ) - store.dispatch({ type: '' }) - store.dispatch({ type: '' }) + rtl.act(() => { + store.dispatch({ type: '' }) + store.dispatch({ type: '' }) + }) outerComponent.current!.setState(({ count }) => ({ count: count + 1 })) expect(reduxCountPassedToMapState).toEqual(3) diff --git a/test/hooks/useReduxContext.spec.tsx b/test/hooks/useReduxContext.spec.tsx index 18b31fb0d..811f1da01 100644 --- a/test/hooks/useReduxContext.spec.tsx +++ b/test/hooks/useReduxContext.spec.tsx @@ -9,7 +9,7 @@ describe('React', () => { const { result } = renderHook(() => useReduxContext()) - expect(result.error.message).toMatch( + expect(result.error?.message).toMatch( /could not find react-redux context value/ ) diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index a9d48f11c..0092288b1 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useReducer, useLayoutEffect } from 'react' import { createStore } from 'redux' -import { renderHook, act } from '@testing-library/react-hooks' +import { renderHook } from '@testing-library/react-hooks' import * as rtl from '@testing-library/react' import { Provider as ProviderMock, @@ -11,14 +11,12 @@ import { connect, createSelectorHook, } from '../../src/index' -import { useReduxContext } from '../../src/hooks/useReduxContext' import type { FunctionComponent, DispatchWithoutAction, ReactNode } from 'react' import type { Store, AnyAction } from 'redux' import type { ProviderProps, TypedUseSelectorHook, ReactReduxContextValue, - Subscription, } from '../../src/' describe('React', () => { @@ -74,7 +72,7 @@ describe('React', () => { expect(result.current).toEqual(0) expect(selector).toHaveBeenCalledTimes(2) - act(() => { + rtl.act(() => { normalStore.dispatch({ type: '' }) }) @@ -102,67 +100,13 @@ describe('React', () => { expect(renderedItems).toEqual([1]) - store.dispatch({ type: '' }) + rtl.act(() => { + store.dispatch({ type: '' }) + }) expect(renderedItems).toEqual([1, 2]) }) - it('subscribes to the store synchronously', () => { - let rootSubscription: Subscription - - const Parent = () => { - const { subscription } = useReduxContext() as ReactReduxContextValue - rootSubscription = subscription - const count = useNormalSelector((s) => s.count) - return count === 1 ? : null - } - - const Child = () => { - const count = useNormalSelector((s) => s.count) - return
{count}
- } - - rtl.render( - - - - ) - // @ts-ignore ts(2454) - expect(rootSubscription.getListeners().get().length).toBe(1) - - normalStore.dispatch({ type: '' }) - // @ts-ignore ts(2454) - expect(rootSubscription.getListeners().get().length).toBe(2) - }) - - it('unsubscribes when the component is unmounted', () => { - let rootSubscription: Subscription - - const Parent = () => { - const { subscription } = useReduxContext() as ReactReduxContextValue - rootSubscription = subscription - const count = useNormalSelector((s) => s.count) - return count === 0 ? : null - } - - const Child = () => { - const count = useNormalSelector((s) => s.count) - return
{count}
- } - - rtl.render( - - - - ) - // @ts-ignore ts(2454) - expect(rootSubscription.getListeners().get().length).toBe(2) - - normalStore.dispatch({ type: '' }) - // @ts-ignore ts(2454) - expect(rootSubscription.getListeners().get().length).toBe(1) - }) - it('notices store updates between render and store subscription effect', () => { const Comp = () => { const count = useNormalSelector((s) => s.count) @@ -279,7 +223,9 @@ describe('React', () => { expect(renderedItems.length).toBe(1) - store.dispatch({ type: '' }) + rtl.act(() => { + store.dispatch({ type: '' }) + }) expect(renderedItems.length).toBe(1) }) @@ -458,7 +404,9 @@ describe('React', () => { ) - normalStore.dispatch({ type: '' }) + rtl.act(() => { + normalStore.dispatch({ type: '' }) + }) expect(sawInconsistentState).toBe(false) @@ -484,7 +432,9 @@ describe('React', () => { expect(renderedItems.length).toBe(1) - normalStore.dispatch({ type: '' }) + rtl.act(() => { + normalStore.dispatch({ type: '' }) + }) expect(renderedItems.length).toBe(2) expect(renderedItems[0]).toBe(renderedItems[1]) diff --git a/test/react-native/batch-integration.tsx b/test/react-native/batch-integration.tsx deleted file mode 100644 index baae8589c..000000000 --- a/test/react-native/batch-integration.tsx +++ /dev/null @@ -1,606 +0,0 @@ -import React, { Component, useLayoutEffect } from 'react' -import { View, Button, Text, unstable_batchedUpdates } from 'react-native' -import { createStore, applyMiddleware } from 'redux' -import { - Provider as ProviderMock, - connect, - batch, - useSelector, - useDispatch, -} from '../../src/index' -import { useIsomorphicLayoutEffect } from '../../src/utils/useIsomorphicLayoutEffect' -import * as rtl from '@testing-library/react-native' -import '@testing-library/jest-native/extend-expect' - -import type { MiddlewareAPI, Dispatch as ReduxDispatch } from 'redux' - -describe('React Native', () => { - const propMapper = (prop: any) => { - switch (typeof prop) { - case 'object': - case 'boolean': - return JSON.stringify(prop) - case 'function': - return '[function ' + prop.name + ']' - default: - return prop - } - } - - interface PassthroughPropsType { - [x: string]: any - } - class Passthrough extends Component { - render() { - return ( - - {Object.keys(this.props).map((prop) => ( - - {propMapper(this.props[prop])} - - ))} - - ) - } - } - interface ActionType { - type: string - body?: string - } - function stringBuilder(prev = '', action: ActionType) { - return action.type === 'APPEND' ? prev + action.body : prev - } - - afterEach(() => rtl.cleanup()) - - describe('batch', () => { - it('batch should be RN unstable_batchedUpdates', () => { - expect(batch).toBe(unstable_batchedUpdates) - }) - }) - - describe('useIsomorphicLayoutEffect', () => { - it('useIsomorphicLayoutEffect should be useLayoutEffect', () => { - expect(useIsomorphicLayoutEffect).toBe(useLayoutEffect) - }) - }) - - describe('Subscription and update timing correctness', () => { - it('should pass state consistently to mapState', () => { - type RootStateType = string - type NoDispatch = {} - - const store = createStore(stringBuilder) - - rtl.act(() => { - store.dispatch({ type: 'APPEND', body: 'a' }) - }) - - let childMapStateInvokes = 0 - interface ContainerTStatePropsType { - state: RootStateType - } - type ContainerOwnOwnPropsType = {} - class Container extends Component { - emitChange() { - store.dispatch({ type: 'APPEND', body: 'b' }) - } - - render() { - return ( - -