Skip to content

Commit 97f29f2

Browse files
committed
Add SSR test for serverState behavior
1 parent 1659df0 commit 97f29f2

File tree

3 files changed

+205
-5
lines changed

3 files changed

+205
-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

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

0 commit comments

Comments
 (0)