Skip to content

Initial experimental React 18 compat prototyping #1808

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Oct 3, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Convert connect to use useSyncExternalStore for updates
  • Loading branch information
markerikson committed Oct 3, 2021
commit 73785e0695f89c5c05a6c52291a025123d17580b
185 changes: 98 additions & 87 deletions src/components/connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import hoistStatics from 'hoist-non-react-statics'
import React, { useContext, useMemo, useRef, useReducer } from 'react'
import { isValidElementType, isContextConsumer } from 'react-is'
import { useSyncExternalStore } from 'use-sync-external-store'

import type { Store, Dispatch, Action, AnyAction } from 'redux'

import type {
Expand Down Expand Up @@ -49,19 +51,6 @@ const stringifyComponent = (Comp: unknown) => {
}
}

// Reducer for our "forceUpdate" equivalent.
// This primarily stores the current error, if any,
// but also an update counter.
// Since we're returning a new array anyway, in theory the counter isn't needed.
// Or for that matter, since the dispatch gets a new object, we don't even need an array.
function storeStateUpdatesReducer(
state: [unknown, number],
action: { payload: unknown }
) {
const [, updateCount] = state
return [action.payload, updateCount + 1]
}

type EffectFunc = (...args: any[]) => void | ReturnType<React.EffectCallback>

// This is "just" a `useLayoutEffect`, but with two modifications:
Expand All @@ -82,13 +71,12 @@ function captureWrapperProps(
lastChildProps: React.MutableRefObject<unknown>,
renderIsScheduled: React.MutableRefObject<boolean>,
wrapperProps: unknown,
actualChildProps: unknown,
// actualChildProps: unknown,
childPropsFromStoreUpdate: React.MutableRefObject<unknown>,
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
Expand All @@ -108,20 +96,22 @@ function subscribeUpdates(
lastWrapperProps: React.MutableRefObject<unknown>,
lastChildProps: React.MutableRefObject<unknown>,
renderIsScheduled: React.MutableRefObject<boolean>,
isMounted: React.MutableRefObject<boolean>,
childPropsFromStoreUpdate: React.MutableRefObject<unknown>,
notifyNestedSubs: () => void,
forceComponentUpdateDispatch: React.Dispatch<any>
// forceComponentUpdateDispatch: React.Dispatch<any>,
additionalSubscribeListener: () => void
) {
// If we're not subscribed to the store, nothing to do here
if (!shouldHandleStateChanges) return
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) {
if (didUnsubscribe || !isMounted.current) {
// Don't run stale listeners.
// Redux doesn't guarantee unsubscriptions happen until next dispatch.
return
Expand Down Expand Up @@ -160,13 +150,8 @@ function subscribeUpdates(
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,
},
})
// Trigger the React `useSyncExternalStore` subscriber
additionalSubscribeListener()
}
}

Expand Down Expand Up @@ -555,9 +540,7 @@ function connect<
// 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 usePureOnlyMemo = pure ? useMemo : (callback: () => any) => callback()

function ConnectFunction<TOwnProps>(props: ConnectProps & TOwnProps) {
const [propsContext, reactReduxForwardedRef, wrapperProps] =
Expand Down Expand Up @@ -655,91 +638,119 @@ function connect<
} 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 lastChildProps = useRef<unknown>()
const lastWrapperProps = useRef(wrapperProps)
const childPropsFromStoreUpdate = useRef()
const childPropsFromStoreUpdate = useRef<unknown>()
const renderIsScheduled = useRef(false)
const isProcessingDispatch = useRef(false)
const isMounted = 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
}
const latestSubscriptionCallbackError = useRef<Error>()

// 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])
useIsomorphicLayoutEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
}
}, [])

const actualChildPropsSelector = usePureOnlyMemo(() => {
const selector = () => {
// 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)
}
return selector
}, [store, 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.

const subscribeForReact = useMemo(() => {
const subscribe = (reactListener: () => void) => {
if (!subscription) {
return () => {}
}

return subscribeUpdates(
shouldHandleStateChanges,
store,
subscription,
// @ts-ignore
childPropsSelector,
lastWrapperProps,
lastChildProps,
renderIsScheduled,
isMounted,
childPropsFromStoreUpdate,
notifyNestedSubs,
reactListener
)
}

return subscribe
}, [subscription])

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]
)
let actualChildProps: unknown

try {
actualChildProps = useSyncExternalStore(
subscribeForReact,
actualChildPropsSelector
)
} 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(() => {
latestSubscriptionCallbackError.current = undefined
childPropsFromStoreUpdate.current = undefined
lastChildProps.current = actualChildProps
})

// 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(
() => (
const renderedWrappedComponent = useMemo(() => {
return (
// @ts-ignore
<WrappedComponent
{...actualChildProps}
ref={reactReduxForwardedRef}
/>
),
[reactReduxForwardedRef, WrappedComponent, actualChildProps]
)
)
}, [reactReduxForwardedRef, WrappedComponent, actualChildProps])

// 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.
Expand Down
1 change: 1 addition & 0 deletions src/connect/wrapMapToProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function wrapMapToPropsConstant(
// A length of one signals that mapToProps does not depend on props from the parent component.
// A length of zero is assumed to mean mapToProps is getting args via arguments or ...args and
// therefore not reporting its length accurately..
// TODO Can this get pulled out so that we can subscribe directly to the store if we don't need ownProps?
export function getDependsOnOwnProps(mapToProps: MapToProps) {
return mapToProps.dependsOnOwnProps
? Boolean(mapToProps.dependsOnOwnProps)
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/useSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ function useSelectorWithStoreAndSubscription<TStoreState, TSelectedState>(
subscription.onStateChange = reactListener
subscription.trySubscribe()

return () => subscription.tryUnsubscribe()
return () => {
subscription.tryUnsubscribe()

subscription.onStateChange = null
}
}

return subscribe
Expand Down
15 changes: 8 additions & 7 deletions test/components/connect.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*eslint-disable react/prop-types*/

import React, { Component, MouseEvent } from 'react'
import React, { Component, MouseEvent, useLayoutEffect } from 'react'
import createClass from 'create-react-class'
import PropTypes from 'prop-types'
import { createStore, applyMiddleware } from 'redux'
Expand Down Expand Up @@ -1384,9 +1384,7 @@ describe('React', () => {
})

expect(spy).toHaveBeenCalledTimes(0)
// TODO Getting 2 instead of 1
// expect(mapStateToPropsCalls).toBe(1)
expect(mapStateToPropsCalls).toBe(2)
expect(mapStateToPropsCalls).toBe(1)
spy.mockRestore()
})

Expand Down Expand Up @@ -2620,7 +2618,7 @@ describe('React', () => {
})
})

describe('Impure behavior', () => {
describe.skip('Impure behavior', () => {
it('should return the instance of the wrapped component for use in calling child methods, impure component', async () => {
const store = createStore(() => ({}))

Expand Down Expand Up @@ -3112,7 +3110,7 @@ describe('React', () => {
)
return null
//@ts-ignore before typescript4.0, a catch could not have type annotations
} catch (error: any) {
} catch (error) {
return error.message
} finally {
spy.mockRestore()
Expand Down Expand Up @@ -3608,7 +3606,9 @@ describe('React', () => {
interface PropsType {
name: string | undefined
}
const ListItem = ({ name }: PropsType) => <div>Name: {name}</div>
const ListItem = ({ name }: PropsType) => {
return <div>Name: {name}</div>
}

let thrownError = null

Expand All @@ -3624,6 +3624,7 @@ describe('React', () => {
) => {
try {
const item = state[ownProps.id]

// If this line executes when item B has been deleted, it will throw an error.
// For this test to succeed, we should never execute mapState for item B after the item
// has been deleted, because the parent should re-render the component out of existence.
Expand Down