Skip to content

Commit 5bca999

Browse files
committed
Add SSR test for serverState behavior
1 parent 1659df0 commit 5bca999

File tree

3 files changed

+214
-5
lines changed

3 files changed

+214
-5
lines changed

Diff for: jest.config.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const tsTestFolderPath = (folderName) =>
1313

1414
const tsStandardConfig = {
1515
...defaults,
16-
displayName: 'ReactDOM 18',
16+
displayName: 'ReactDOM 18 (Shim)',
1717
preset: 'ts-jest',
1818
testMatch: NORMAL_TEST_FOLDERS.map(tsTestFolderPath),
1919
}
@@ -42,7 +42,7 @@ const standardReact17Config = {
4242

4343
const nextEntryConfig = {
4444
...tsStandardConfig,
45-
displayName: 'Next',
45+
displayName: 'ReactDOM 18 (Next)',
4646
moduleNameMapper: {
4747
'../../src/index': '<rootDir>/src/next',
4848
},

Diff for: test/components/connect.spec.tsx

+1-3
Original file line numberDiff line numberDiff line change
@@ -530,9 +530,7 @@ describe('React', () => {
530530

531531
const ConnectedInner = connect(
532532
(state) => ({ stateThing: state }),
533-
(dispatch) => ({
534-
doSomething: (whatever: any) => dispatch(doSomething(whatever)),
535-
}),
533+
{ doSomething },
536534
(stateProps, actionProps, parentProps: InnerPropsType) => ({
537535
...stateProps,
538536
...actionProps,

Diff for: test/integration/ssr.spec.tsx

+211
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
*
3+
* Set this so that `window` is undefined to correctly mimic a Node SSR scenario.
4+
* That allows connect to fall back to `useEffect` instead of `useLayoutEffect`
5+
* to avoid ugly console warnings when used with SSR.
6+
*/
7+
8+
/*eslint-disable react/prop-types*/
9+
10+
import React, { Suspense, useState, useEffect } from 'react'
11+
import * as rtl from '@testing-library/react'
12+
import { renderToString } from 'react-dom/server'
13+
import { hydrateRoot } from 'react-dom/client'
14+
import { createStore, createSlice, PayloadAction } from '@reduxjs/toolkit'
15+
import {
16+
Provider,
17+
connect,
18+
useSelector,
19+
useDispatch,
20+
ConnectedProps,
21+
} from '../../src/index'
22+
23+
const IS_REACT_18 = React.version.startsWith('18')
24+
25+
describe('New v8 serverState behavior', () => {
26+
interface State {
27+
count: number
28+
data: string[]
29+
}
30+
const initialState: State = {
31+
count: 0,
32+
data: [],
33+
}
34+
35+
const dataSlice = createSlice({
36+
name: 'data',
37+
initialState,
38+
reducers: {
39+
fakeLoadData(state, action: PayloadAction<string>) {
40+
state.data.push(action.payload)
41+
},
42+
increaseCount(state) {
43+
state.count++
44+
},
45+
},
46+
})
47+
48+
const { fakeLoadData, increaseCount } = dataSlice.actions
49+
50+
const selectCount = (state: State) => state.count
51+
52+
function useIsHydrated() {
53+
// Get weird Babel-errors when I try to destruct arrays..
54+
const hydratedState = useState(false)
55+
const hydrated = hydratedState[0]
56+
const setHydrated = hydratedState[1]
57+
58+
// When this effect runs and the component being hydrated isn't
59+
// exactly the same thing but close enough for this demo.
60+
useEffect(() => {
61+
setHydrated(true)
62+
}, [setHydrated])
63+
64+
return hydrated
65+
}
66+
67+
function GlobalCountButton() {
68+
const isHydrated = useIsHydrated()
69+
const count = useSelector(selectCount)
70+
const dispatch = useDispatch()
71+
72+
return (
73+
<button
74+
disabled={!isHydrated}
75+
style={{ marginLeft: '24px' }}
76+
onClick={() => dispatch(increaseCount())}
77+
>
78+
useSelector:
79+
{isHydrated
80+
? `Hydrated. Count: ${count}`
81+
: `Not hydrated. Count: ${count}`}
82+
</button>
83+
)
84+
}
85+
86+
const mapState = (state: State) => ({
87+
count: selectCount(state),
88+
})
89+
90+
const gcbConnector = connect(mapState)
91+
type PropsFromRedux = ConnectedProps<typeof gcbConnector>
92+
93+
function GlobalCountButtonConnect({ count, dispatch }: PropsFromRedux) {
94+
const isHydrated = useIsHydrated()
95+
96+
return (
97+
<button
98+
disabled={!isHydrated}
99+
style={{ marginLeft: '24px' }}
100+
onClick={() => dispatch(increaseCount())}
101+
>
102+
Connect:
103+
{isHydrated
104+
? `Hydrated. Count: ${count}`
105+
: `Not hydrated. Count: ${count}`}
106+
</button>
107+
)
108+
}
109+
110+
const ConnectedGlobalCountButtonConnect = gcbConnector(
111+
GlobalCountButtonConnect
112+
)
113+
114+
function App() {
115+
return (
116+
<div>
117+
<Suspense fallback={<Spinner />}>
118+
<GlobalCountButton />
119+
<ConnectedGlobalCountButtonConnect />
120+
</Suspense>
121+
</div>
122+
)
123+
}
124+
125+
const Spinner = () => <div />
126+
127+
if (!IS_REACT_18) {
128+
it('Dummy test for React 17, ignore', () => {})
129+
return
130+
}
131+
132+
let consoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
133+
134+
afterEach(() => {
135+
jest.clearAllMocks()
136+
})
137+
138+
it.only('Handles hydration correctly', async () => {
139+
const ssrStore = createStore(dataSlice.reducer)
140+
141+
// Simulating loading all data before rendering the app
142+
ssrStore.dispatch(fakeLoadData("Wait, it doesn't wait for React to load?"))
143+
ssrStore.dispatch(fakeLoadData('How does this even work?'))
144+
ssrStore.dispatch(fakeLoadData('I like marshmallows'))
145+
146+
const markup = renderToString(
147+
<Provider store={ssrStore}>
148+
<App />
149+
</Provider>
150+
)
151+
152+
// Pretend we have server-rendered HTML
153+
const rootDiv = document.createElement('div')
154+
document.body.appendChild(rootDiv)
155+
rootDiv.innerHTML = markup
156+
157+
const initialState = ssrStore.getState()
158+
const clientStore = createStore(dataSlice.reducer, initialState)
159+
160+
// Intentionally update client store to change state vs server
161+
clientStore.dispatch(increaseCount())
162+
163+
// First hydration attempt with just the store should fail due to mismatch
164+
await rtl.act(async () => {
165+
hydrateRoot(
166+
rootDiv,
167+
<Provider store={clientStore}>
168+
<App />
169+
</Provider>
170+
)
171+
})
172+
173+
const [lastCall = []] = consoleError.mock.calls.slice(-1)
174+
const [errorArg] = lastCall
175+
expect(errorArg).toBeInstanceOf(Error)
176+
expect(/There was an error while hydrating/.test(errorArg.message)).toBe(
177+
true
178+
)
179+
180+
jest.resetAllMocks()
181+
182+
expect(consoleError.mock.calls.length).toBe(0)
183+
184+
document.body.removeChild(rootDiv)
185+
186+
const clientStore2 = createStore(dataSlice.reducer, initialState)
187+
clientStore2.dispatch(increaseCount())
188+
189+
const rootDiv2 = document.createElement('div')
190+
document.body.appendChild(rootDiv2)
191+
rootDiv2.innerHTML = markup
192+
193+
// Second attempt should pass, because we provide serverState
194+
await rtl.act(async () => {
195+
hydrateRoot(
196+
rootDiv2,
197+
<Provider store={clientStore2} serverState={initialState}>
198+
<App />
199+
</Provider>
200+
)
201+
})
202+
203+
expect(consoleError.mock.calls.length).toBe(0)
204+
205+
// Buttons should both exist, and have the updated count due to later render
206+
const button1 = rtl.screen.getByText('useSelector:Hydrated. Count: 1')
207+
expect(button1).not.toBeNull()
208+
const button2 = rtl.screen.getByText('Connect:Hydrated. Count: 1')
209+
expect(button2).not.toBeNull()
210+
})
211+
})

0 commit comments

Comments
 (0)