-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
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
Add initial SSR support for React 18 and React-Redux v8 #1835
Changes from all commits
bc4569f
63f626b
ff4f9d1
ed8fdc8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,32 +4,40 @@ import { createSubscription } from '../utils/Subscription' | |
import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' | ||
import { Action, AnyAction, Store } from 'redux' | ||
|
||
export interface ProviderProps<A extends Action = AnyAction> { | ||
export interface ProviderProps<A extends Action = AnyAction, S = any> { | ||
/** | ||
* The single Redux store in your application. | ||
*/ | ||
store: Store<any, A> | ||
store: Store<S, A> | ||
|
||
/** | ||
* An optional server state snapshot. Will be used during initial hydration render if available, to ensure that the UI output is consistent with the HTML generated on the server. | ||
*/ | ||
serverState?: S | ||
|
||
/** | ||
* Optional context to be used internally in react-redux. Use React.createContext() to create a context to be used. | ||
* If this is used, you'll need to customize `connect` by supplying the same context provided to the Provider. | ||
* Initial value doesn't matter, as it is overwritten with the internal state of Provider. | ||
*/ | ||
context?: Context<ReactReduxContextValue<any, A>> | ||
context?: Context<ReactReduxContextValue<S, A>> | ||
children: ReactNode | ||
} | ||
|
||
function Provider<A extends Action = AnyAction>({ | ||
store, | ||
context, | ||
children, | ||
serverState, | ||
}: ProviderProps<A>) { | ||
const contextValue = useMemo(() => { | ||
const subscription = createSubscription(store) | ||
return { | ||
store, | ||
subscription, | ||
getServerState: serverState ? () => serverState : undefined, | ||
} | ||
}, [store]) | ||
}, [store, serverState]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I understand correctly the |
||
|
||
const previousState = useMemo(() => store.getState(), [store]) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -27,7 +27,10 @@ import defaultMapStateToPropsFactories from '../connect/mapStateToProps' | |
import defaultMergePropsFactories from '../connect/mergeProps' | ||
|
||
import { createSubscription, Subscription } from '../utils/Subscription' | ||
import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' | ||
import { | ||
useIsomorphicLayoutEffect, | ||
canUseDOM, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I see correctly this is a leftover - this actually ain't used in this file atm. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sort of the other way around. I made this change speculating I might need to use this in |
||
} from '../utils/useIsomorphicLayoutEffect' | ||
import shallowEqual from '../utils/shallowEqual' | ||
|
||
import { | ||
|
@@ -124,6 +127,7 @@ function subscribeUpdates( | |
return | ||
} | ||
|
||
// TODO We're currently calling getState ourselves here, rather than letting `uSES` do it | ||
const latestStoreState = store.getState() | ||
|
||
let newChildProps, error | ||
|
@@ -157,6 +161,7 @@ function subscribeUpdates( | |
childPropsFromStoreUpdate.current = newChildProps | ||
renderIsScheduled.current = true | ||
|
||
// TODO This is hacky and not how `uSES` is meant to be used | ||
// Trigger the React `useSyncExternalStore` subscriber | ||
additionalSubscribeListener() | ||
} | ||
|
@@ -597,6 +602,10 @@ function connect< | |
? props.store! | ||
: contextValue!.store | ||
|
||
const getServerState = didStoreComeFromContext | ||
? contextValue.getServerState | ||
: store.getState | ||
|
||
const childPropsSelector = useMemo(() => { | ||
// The child props selector needs the store reference as an input. | ||
// Re-create this selector whenever the store changes. | ||
|
@@ -724,10 +733,14 @@ function connect< | |
|
||
try { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. q: when this can throw with |
||
actualChildProps = useSyncExternalStore( | ||
// TODO We're passing through a big wrapper that does a bunch of extra side effects besides subscribing | ||
subscribeForReact, | ||
// TODO This is incredibly hacky. We've already processed the store update and calculated new child props, | ||
// TODO and we're just passing that through so it triggers a re-render for us rather than relying on `uSES`. | ||
actualChildPropsSelector, | ||
// TODO Need a real getServerSnapshot here | ||
actualChildPropsSelector | ||
getServerState | ||
? () => childPropsSelector(getServerState(), wrapperProps) | ||
: actualChildPropsSelector | ||
) | ||
} catch (err) { | ||
if (latestSubscriptionCallbackError.current) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -48,13 +48,13 @@ export function createSelectorHook( | |
} | ||
} | ||
|
||
const { store } = useReduxContext()! | ||
const { store, getServerState } = useReduxContext()! | ||
|
||
const selectedState = useSyncExternalStoreWithSelector( | ||
store.subscribe, | ||
store.getState, | ||
// TODO Need a server-side snapshot here | ||
store.getState, | ||
getServerState || store.getState, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder - do you maybe have a good reference for the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
facebook/react#22361 might help. Note that my initial post made some false assumptions that were corrected.
By the time a particular component is hydrated, the store might've already been mutated. Simplest example I encountered was something like <MutatesStoreInAnEffect />
<ReadsStore />
<React.Suspense>
<ReadsStore />
</React.Suspense> Assuming no component suspends, the server would render the same value for the store in every So we need an API to tell React with what state the markup was rendered on the server.
Not sure I understand this statement correctly but React does need There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for the link and the explanation. This is what I was roughly expecting. I didn't clearly understand how the client value is handled though, and according to Andrew, the "mismatched" component is just re-rendered on top of the hydrated component that was hydrated based on the server snapshot.
I'm wondering about the situation when no external store is involved. Just replace So I wonder, how the implementation is different for both and how React can handle this better for a plain state value? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The issue includes this exact scenario (the codesandbox includes an implementation that uses context+state). How this is implemented I don't know and I'm pretty sure it's better I don't know. Though I suspect it has to do with individual fibers having an alternate or work-in-progress version (terminology might be off). But you can probably find out with the debugger and https://codesandbox.io/s/react-18-updating-store-in-an-effect-during-mount-causes-hydration-mismatch-uses-m6lwm?file=/src/index.js:1927-2103 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One thing I've learned from my past attempts at debugging React code is that I'm not that good at reasoning about such sublime code 🤣 Maybe @acdlite would be able to shed some light on this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The reason useState "just works" is because you can't update a component until after it has already hydrated |
||
selector, | ||
equalityFn | ||
) | ||
|
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -9,9 +9,11 @@ import { useEffect, useLayoutEffect } from 'react' | |||||||
// is created synchronously, otherwise a store update may occur before the | ||||||||
// subscription is created and an inconsistent state may be observed | ||||||||
|
||||||||
export const useIsomorphicLayoutEffect = | ||||||||
// Matches logic in React's `shared/ExecutionEnvironment` file | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: using an actual URL makes it easier for people to jump quickly to referenced files (if needed):
Suggested change
|
||||||||
export const canUseDOM = !!( | ||||||||
typeof window !== 'undefined' && | ||||||||
typeof window.document !== 'undefined' && | ||||||||
typeof window.document.createElement !== 'undefined' | ||||||||
? useLayoutEffect | ||||||||
: useEffect | ||||||||
) | ||||||||
|
||||||||
export const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffect |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
q: this component still relies on a custom Subscription and custom notification propagation etc. Doesn't
useSyncExternalStore
solve the ordering issue?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, not at all.
uSES
still creates store subscriptions same as always, and React still runs effects bottom-to-top on mount. So, children still subscribe before parents, etc, and we still have to do work on our side to enforce the "top-down updates" requirement forconnect
.The only thing in React that has ever helped with that was when we switched v6 to read state updates from context. It worked.... which is why it was so frustrating that it also turned out to be the worst case for performance updates.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't the problem relevant to
useSelector
too? Is React subscribing to the "external store" (Redux) withuSES
still in the order that makes shenanigans required? Is the zombie children problem still relevant withuSES
? Or is the problem somewhat fixed with automatic batching and top-down rerender that should happen with it? As automatic batching should end up rerendering things in top-down order, right?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the problem is entirely relevant to
useSelector
, and there's absolutely nothing we can do to fix it - because hooks can't render context providers, so we can't enforce top-down behavior. That's why I documented all the "zombie child" concepts in the hooks API page in the first place:https://react-redux.js.org/api/hooks#stale-props-and-zombie-children
And no, batching still has nothing to do with this problem, because it's the subscription callbacks themselves that are the problem, and those run outside of React when the store is updated.
The hacky-but-valid workaround that both
useSelector
anduSES
take is catching errors in a selector when subscribers are notified, forcing a re-render anyway, re-running the selector during the render, and seeing if it explodes again or succeeds.That workaround is good enough that it does actually mostly mitigate the issue. But the root cause of the problem still exists - components can end up subscribing to the store in an order where a child is notified before its parent, and if the child depends on using props in its selector, those props can be stale or the child may be reading state right before its parent is about to stop rendering the child.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I understand how automatic batching is not the remedy for the problem.
I must say that I'm somewhat disappointed with the
uSES
API then. I was under the impression that this problem would actually be solved by this API. This problem is affecting all external stores and thus the implementation that would guard users against this problem is still very complicated - so marketinguSES
as a tool solving external store problems is somewhat misleading. I think that what it solves was way easier to solve in the userland (maybe apart from allowing inline selectors, as that was super tricky) than the zombie children problem - solving this problem at the React level would be the bigger "win" for the library authors.@acdlite is solving this problem out of the scope of
uSES
? How are other external stores supposed to handle those situations? Should we use the hacky solution outlined above by @markerikson?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW
uSES
already implements that "hacky solution" :) and specifically inspired byuseSelector
doing it first.Really the problem is a mismatch in data shapes: a linear array of subscriber callbacks executed in order, vs a tree of components where A) mount effects are executed bottom-to-top, and B) components can be added and removed over time.
If there were some way to actually correlate "adding a subscriber" with "actual position in the React component tree", then it would be theoretically possible to build a smarter notification system that actually notifies subscribers in tree parent->child order. But there's no way to know where a given component is in the tree.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would put the asterisk here that it's no way to know that in the userland - that's why I think this is the problem worth solving in the React itself (in the
uSES
).Hm, so I'm a little bit confused right now. I wonder if I understand the whole zombie children problem right now - I think I understand the problem but I might not understand how the problem is solved in React Redux today.
So to get us on the same page. This is what I think right now:
Is this reasoning correct? I think that I see where the
use-sync-external-store/shim
implements the try/catch dance and that looks good. It means that libraries using that won't have to deal with this problem at all because that's covered there. I wonder though - if the implementation of the "native"useSyncExternalStore
the same or is the issue mitigated there because things can actually be executed in the "correct" order in React 18?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, that's all correct. (fwiw I don't think the "cost" thing in step 5 is really a meaningful issue.)
You can see that the actual
useSyncExternalStore
does the same 'rerender if error" behavior here:https://github.com/facebook/react/blob/3dc41d8a2590768a6ac906cd1f4c11ca00417eee/packages/react-reconciler/src/ReactFiberHooks.new.js#L1482-L1491
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that this could have been somewhat exaggerated on my side. If this is only affecting subscribers and not rerenders then I guess this is somewhat OK (I assume that the inner component might in fact not even rerender, even if it throws, if it gets "unmounted" by the parent). However, I still find it a little bit strange that React 18 doesn't try to mitigate this all together in a smarter way - but dunno, maybe the smarter way would have some perf implications for the whole thing.