Skip to content
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 SSR test for serverState behavior #1888

Merged
merged 1 commit into from
Apr 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const tsTestFolderPath = (folderName) =>

const tsStandardConfig = {
...defaults,
displayName: 'ReactDOM 18',
displayName: 'ReactDOM 18 (Shim)',
preset: 'ts-jest',
testMatch: NORMAL_TEST_FOLDERS.map(tsTestFolderPath),
}
Expand Down Expand Up @@ -42,7 +42,7 @@ const standardReact17Config = {

const nextEntryConfig = {
...tsStandardConfig,
displayName: 'Next',
displayName: 'ReactDOM 18 (Next)',
moduleNameMapper: {
'../../src/index': '<rootDir>/src/next',
},
Expand Down
4 changes: 1 addition & 3 deletions test/components/connect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,9 +530,7 @@ describe('React', () => {

const ConnectedInner = connect(
(state) => ({ stateThing: state }),
(dispatch) => ({
doSomething: (whatever: any) => dispatch(doSomething(whatever)),
}),
{ doSomething },
(stateProps, actionProps, parentProps: InnerPropsType) => ({
...stateProps,
...actionProps,
Expand Down
202 changes: 202 additions & 0 deletions test/integration/ssr.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import React, { Suspense, useState, useEffect } from 'react'
import * as rtl from '@testing-library/react'
import { renderToString } from 'react-dom/server'
import { hydrateRoot } from 'react-dom/client'
import { createStore, createSlice, PayloadAction } from '@reduxjs/toolkit'
import {
Provider,
connect,
useSelector,
useDispatch,
ConnectedProps,
} from '../../src/index'

const IS_REACT_18 = React.version.startsWith('18')

describe('New v8 serverState behavior', () => {
interface State {
count: number
data: string[]
}
const initialState: State = {
count: 0,
data: [],
}

const dataSlice = createSlice({
name: 'data',
initialState,
reducers: {
fakeLoadData(state, action: PayloadAction<string>) {
state.data.push(action.payload)
},
increaseCount(state) {
state.count++
},
},
})

const { fakeLoadData, increaseCount } = dataSlice.actions

const selectCount = (state: State) => state.count

function useIsHydrated() {
// Get weird Babel-errors when I try to destruct arrays..
const hydratedState = useState(false)
const hydrated = hydratedState[0]
const setHydrated = hydratedState[1]

// When this effect runs and the component being hydrated isn't
// exactly the same thing but close enough for this demo.
useEffect(() => {
setHydrated(true)
}, [setHydrated])

return hydrated
}

function GlobalCountButton() {
const isHydrated = useIsHydrated()
const count = useSelector(selectCount)
const dispatch = useDispatch()

return (
<button
disabled={!isHydrated}
style={{ marginLeft: '24px' }}
onClick={() => dispatch(increaseCount())}
>
useSelector:
{isHydrated
? `Hydrated. Count: ${count}`
: `Not hydrated. Count: ${count}`}
</button>
)
}

const mapState = (state: State) => ({
count: selectCount(state),
})

const gcbConnector = connect(mapState)
type PropsFromRedux = ConnectedProps<typeof gcbConnector>

function GlobalCountButtonConnect({ count, dispatch }: PropsFromRedux) {
const isHydrated = useIsHydrated()

return (
<button
disabled={!isHydrated}
style={{ marginLeft: '24px' }}
onClick={() => dispatch(increaseCount())}
>
Connect:
{isHydrated
? `Hydrated. Count: ${count}`
: `Not hydrated. Count: ${count}`}
</button>
)
}

const ConnectedGlobalCountButtonConnect = gcbConnector(
GlobalCountButtonConnect
)

function App() {
return (
<div>
<Suspense fallback={<Spinner />}>
<GlobalCountButton />
<ConnectedGlobalCountButtonConnect />
</Suspense>
</div>
)
}

const Spinner = () => <div />

if (!IS_REACT_18) {
it('Dummy test for React 17, ignore', () => {})
return
}

let consoleError = jest.spyOn(console, 'error').mockImplementation(() => {})

afterEach(() => {
jest.clearAllMocks()
})

it.only('Handles hydration correctly', async () => {
const ssrStore = createStore(dataSlice.reducer)

// Simulating loading all data before rendering the app
ssrStore.dispatch(fakeLoadData("Wait, it doesn't wait for React to load?"))
ssrStore.dispatch(fakeLoadData('How does this even work?'))
ssrStore.dispatch(fakeLoadData('I like marshmallows'))

const markup = renderToString(
<Provider store={ssrStore}>
<App />
</Provider>
)

// Pretend we have server-rendered HTML
const rootDiv = document.createElement('div')
document.body.appendChild(rootDiv)
rootDiv.innerHTML = markup

const initialState = ssrStore.getState()
const clientStore = createStore(dataSlice.reducer, initialState)

// Intentionally update client store to change state vs server
clientStore.dispatch(increaseCount())

// First hydration attempt with just the store should fail due to mismatch
await rtl.act(async () => {
hydrateRoot(
rootDiv,
<Provider store={clientStore}>
<App />
</Provider>
)
})

const [lastCall = []] = consoleError.mock.calls.slice(-1)
const [errorArg] = lastCall
expect(errorArg).toBeInstanceOf(Error)
expect(/There was an error while hydrating/.test(errorArg.message)).toBe(
true
)

jest.resetAllMocks()

expect(consoleError.mock.calls.length).toBe(0)

document.body.removeChild(rootDiv)

const clientStore2 = createStore(dataSlice.reducer, initialState)
clientStore2.dispatch(increaseCount())

const rootDiv2 = document.createElement('div')
document.body.appendChild(rootDiv2)
rootDiv2.innerHTML = markup

// Second attempt should pass, because we provide serverState
await rtl.act(async () => {
hydrateRoot(
rootDiv2,
<Provider store={clientStore2} serverState={initialState}>
<App />
</Provider>
)
})

expect(consoleError.mock.calls.length).toBe(0)

// Buttons should both exist, and have the updated count due to later render
const button1 = rtl.screen.getByText('useSelector:Hydrated. Count: 1')
expect(button1).not.toBeNull()
const button2 = rtl.screen.getByText('Connect:Hydrated. Count: 1')
expect(button2).not.toBeNull()
})
})