22import hoistStatics from 'hoist-non-react-statics'
33import React , { useContext , useMemo , useRef , useReducer } from 'react'
44import { isValidElementType , isContextConsumer } from 'react-is'
5+ import { useSyncExternalStore } from 'use-sync-external-store'
6+
57import type { Store , Dispatch , Action , AnyAction } from 'redux'
68
79import type {
@@ -49,19 +51,6 @@ const stringifyComponent = (Comp: unknown) => {
4951 }
5052}
5153
52- // Reducer for our "forceUpdate" equivalent.
53- // This primarily stores the current error, if any,
54- // but also an update counter.
55- // Since we're returning a new array anyway, in theory the counter isn't needed.
56- // Or for that matter, since the dispatch gets a new object, we don't even need an array.
57- function storeStateUpdatesReducer (
58- state : [ unknown , number ] ,
59- action : { payload : unknown }
60- ) {
61- const [ , updateCount ] = state
62- return [ action . payload , updateCount + 1 ]
63- }
64-
6554type EffectFunc = ( ...args : any [ ] ) => void | ReturnType < React . EffectCallback >
6655
6756// This is "just" a `useLayoutEffect`, but with two modifications:
@@ -82,13 +71,12 @@ function captureWrapperProps(
8271 lastChildProps : React . MutableRefObject < unknown > ,
8372 renderIsScheduled : React . MutableRefObject < boolean > ,
8473 wrapperProps : unknown ,
85- actualChildProps : unknown ,
74+ // actualChildProps: unknown,
8675 childPropsFromStoreUpdate : React . MutableRefObject < unknown > ,
8776 notifyNestedSubs : ( ) => void
8877) {
8978 // We want to capture the wrapper props and child props we used for later comparisons
9079 lastWrapperProps . current = wrapperProps
91- lastChildProps . current = actualChildProps
9280 renderIsScheduled . current = false
9381
9482 // If the render was from a store update, clear out that reference and cascade the subscriber update
@@ -108,20 +96,22 @@ function subscribeUpdates(
10896 lastWrapperProps : React . MutableRefObject < unknown > ,
10997 lastChildProps : React . MutableRefObject < unknown > ,
11098 renderIsScheduled : React . MutableRefObject < boolean > ,
99+ isMounted : React . MutableRefObject < boolean > ,
111100 childPropsFromStoreUpdate : React . MutableRefObject < unknown > ,
112101 notifyNestedSubs : ( ) => void ,
113- forceComponentUpdateDispatch : React . Dispatch < any >
102+ // forceComponentUpdateDispatch: React.Dispatch<any>,
103+ additionalSubscribeListener : ( ) => void
114104) {
115105 // If we're not subscribed to the store, nothing to do here
116- if ( ! shouldHandleStateChanges ) return
106+ if ( ! shouldHandleStateChanges ) return ( ) => { }
117107
118108 // Capture values for checking if and when this component unmounts
119109 let didUnsubscribe = false
120110 let lastThrownError : Error | null = null
121111
122112 // We'll run this callback every time a store subscription update propagates to this component
123113 const checkForUpdates = ( ) => {
124- if ( didUnsubscribe ) {
114+ if ( didUnsubscribe || ! isMounted . current ) {
125115 // Don't run stale listeners.
126116 // Redux doesn't guarantee unsubscriptions happen until next dispatch.
127117 return
@@ -160,13 +150,8 @@ function subscribeUpdates(
160150 childPropsFromStoreUpdate . current = newChildProps
161151 renderIsScheduled . current = true
162152
163- // If the child props _did_ change (or we caught an error), this wrapper component needs to re-render
164- forceComponentUpdateDispatch ( {
165- type : 'STORE_UPDATED' ,
166- payload : {
167- error,
168- } ,
169- } )
153+ // Trigger the React `useSyncExternalStore` subscriber
154+ additionalSubscribeListener ( )
170155 }
171156 }
172157
@@ -555,9 +540,7 @@ function connect<
555540 // If we aren't running in "pure" mode, we don't want to memoize values.
556541 // To avoid conditionally calling hooks, we fall back to a tiny wrapper
557542 // that just executes the given callback immediately.
558- const usePureOnlyMemo = pure
559- ? useMemo
560- : ( callback : ( ) => void ) => callback ( )
543+ const usePureOnlyMemo = pure ? useMemo : ( callback : ( ) => any ) => callback ( )
561544
562545 function ConnectFunction < TOwnProps > ( props : ConnectProps & TOwnProps ) {
563546 const [ propsContext , reactReduxForwardedRef , wrapperProps ] =
@@ -655,91 +638,119 @@ function connect<
655638 } as ReactReduxContextValue
656639 } , [ didStoreComeFromProps , contextValue , subscription ] )
657640
658- // We need to force this wrapper component to re-render whenever a Redux store update
659- // causes a change to the calculated child component props (or we caught an error in mapState)
660- const [ [ previousStateUpdateResult ] , forceComponentUpdateDispatch ] =
661- useReducer (
662- storeStateUpdatesReducer ,
663- // @ts -ignore
664- EMPTY_ARRAY as any ,
665- initStateUpdates
666- )
667-
668- // Propagate any mapState/mapDispatch errors upwards
669- if ( previousStateUpdateResult && previousStateUpdateResult . error ) {
670- throw previousStateUpdateResult . error
671- }
672-
673641 // Set up refs to coordinate values between the subscription effect and the render logic
674- const lastChildProps = useRef ( )
642+ const lastChildProps = useRef < unknown > ( )
675643 const lastWrapperProps = useRef ( wrapperProps )
676- const childPropsFromStoreUpdate = useRef ( )
644+ const childPropsFromStoreUpdate = useRef < unknown > ( )
677645 const renderIsScheduled = useRef ( false )
646+ const isProcessingDispatch = useRef ( false )
647+ const isMounted = useRef ( false )
678648
679- const actualChildProps = usePureOnlyMemo ( ( ) => {
680- // Tricky logic here:
681- // - This render may have been triggered by a Redux store update that produced new child props
682- // - However, we may have gotten new wrapper props after that
683- // If we have new child props, and the same wrapper props, we know we should use the new child props as-is.
684- // But, if we have new wrapper props, those might change the child props, so we have to recalculate things.
685- // So, we'll use the child props from store update only if the wrapper props are the same as last time.
686- if (
687- childPropsFromStoreUpdate . current &&
688- wrapperProps === lastWrapperProps . current
689- ) {
690- return childPropsFromStoreUpdate . current
691- }
649+ const latestSubscriptionCallbackError = useRef < Error > ( )
692650
693- // TODO We're reading the store directly in render() here. Bad idea?
694- // This will likely cause Bad Things (TM) to happen in Concurrent Mode.
695- // Note that we do this because on renders _not_ caused by store updates, we need the latest store state
696- // to determine what the child props should be.
697- return childPropsSelector ( store . getState ( ) , wrapperProps )
698- } , [ store , previousStateUpdateResult , wrapperProps ] )
651+ useIsomorphicLayoutEffect ( ( ) => {
652+ isMounted . current = true
653+ return ( ) => {
654+ isMounted . current = false
655+ }
656+ } , [ ] )
657+
658+ const actualChildPropsSelector = usePureOnlyMemo ( ( ) => {
659+ const selector = ( ) => {
660+ // Tricky logic here:
661+ // - This render may have been triggered by a Redux store update that produced new child props
662+ // - However, we may have gotten new wrapper props after that
663+ // If we have new child props, and the same wrapper props, we know we should use the new child props as-is.
664+ // But, if we have new wrapper props, those might change the child props, so we have to recalculate things.
665+ // So, we'll use the child props from store update only if the wrapper props are the same as last time.
666+ if (
667+ childPropsFromStoreUpdate . current &&
668+ wrapperProps === lastWrapperProps . current
669+ ) {
670+ return childPropsFromStoreUpdate . current
671+ }
672+
673+ // TODO We're reading the store directly in render() here. Bad idea?
674+ // This will likely cause Bad Things (TM) to happen in Concurrent Mode.
675+ // Note that we do this because on renders _not_ caused by store updates, we need the latest store state
676+ // to determine what the child props should be.
677+ return childPropsSelector ( store . getState ( ) , wrapperProps )
678+ }
679+ return selector
680+ } , [ store , wrapperProps ] )
699681
700682 // We need this to execute synchronously every time we re-render. However, React warns
701683 // about useLayoutEffect in SSR, so we try to detect environment and fall back to
702684 // just useEffect instead to avoid the warning, since neither will run anyway.
685+
686+ const subscribeForReact = useMemo ( ( ) => {
687+ const subscribe = ( reactListener : ( ) => void ) => {
688+ if ( ! subscription ) {
689+ return ( ) => { }
690+ }
691+
692+ return subscribeUpdates (
693+ shouldHandleStateChanges ,
694+ store ,
695+ subscription ,
696+ // @ts -ignore
697+ childPropsSelector ,
698+ lastWrapperProps ,
699+ lastChildProps ,
700+ renderIsScheduled ,
701+ isMounted ,
702+ childPropsFromStoreUpdate ,
703+ notifyNestedSubs ,
704+ reactListener
705+ )
706+ }
707+
708+ return subscribe
709+ } , [ subscription ] )
710+
703711 useIsomorphicLayoutEffectWithArgs ( captureWrapperProps , [
704712 lastWrapperProps ,
705713 lastChildProps ,
706714 renderIsScheduled ,
707715 wrapperProps ,
708- actualChildProps ,
709716 childPropsFromStoreUpdate ,
710717 notifyNestedSubs ,
711718 ] )
712719
713- // Our re-subscribe logic only runs when the store/subscription setup changes
714- useIsomorphicLayoutEffectWithArgs (
715- subscribeUpdates ,
716- [
717- shouldHandleStateChanges ,
718- store ,
719- subscription ,
720- childPropsSelector ,
721- lastWrapperProps ,
722- lastChildProps ,
723- renderIsScheduled ,
724- childPropsFromStoreUpdate ,
725- notifyNestedSubs ,
726- forceComponentUpdateDispatch ,
727- ] ,
728- [ store , subscription , childPropsSelector ]
729- )
720+ let actualChildProps : unknown
721+
722+ try {
723+ actualChildProps = useSyncExternalStore (
724+ subscribeForReact ,
725+ actualChildPropsSelector
726+ )
727+ } catch ( err ) {
728+ if ( latestSubscriptionCallbackError . current ) {
729+ ; (
730+ err as Error
731+ ) . message += `\nThe error may be correlated with this previous error:\n${ latestSubscriptionCallbackError . current . stack } \n\n`
732+ }
733+
734+ throw err
735+ }
736+
737+ useIsomorphicLayoutEffect ( ( ) => {
738+ latestSubscriptionCallbackError . current = undefined
739+ childPropsFromStoreUpdate . current = undefined
740+ lastChildProps . current = actualChildProps
741+ } )
730742
731743 // Now that all that's done, we can finally try to actually render the child component.
732744 // We memoize the elements for the rendered child component as an optimization.
733- const renderedWrappedComponent = useMemo (
734- ( ) => (
745+ const renderedWrappedComponent = useMemo ( ( ) => {
746+ return (
735747 // @ts -ignore
736748 < WrappedComponent
737749 { ...actualChildProps }
738750 ref = { reactReduxForwardedRef }
739751 />
740- ) ,
741- [ reactReduxForwardedRef , WrappedComponent , actualChildProps ]
742- )
752+ )
753+ } , [ reactReduxForwardedRef , WrappedComponent , actualChildProps ] )
743754
744755 // If React sees the exact same element reference as last time, it bails out of re-rendering
745756 // that child, same as if it was wrapped in React.memo() or returned false from shouldComponentUpdate.
0 commit comments