From 97f29f2cce917703a1a907eafead0792fe896ee1 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 10 Apr 2022 17:57:26 -0400 Subject: [PATCH] Add SSR test for `serverState` behavior --- jest.config.js | 4 +- test/components/connect.spec.tsx | 4 +- test/integration/ssr.spec.tsx | 202 +++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 test/integration/ssr.spec.tsx diff --git a/jest.config.js b/jest.config.js index 4ad9ed38b..11296c56a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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), } @@ -42,7 +42,7 @@ const standardReact17Config = { const nextEntryConfig = { ...tsStandardConfig, - displayName: 'Next', + displayName: 'ReactDOM 18 (Next)', moduleNameMapper: { '../../src/index': '/src/next', }, diff --git a/test/components/connect.spec.tsx b/test/components/connect.spec.tsx index e50c80ae6..1007b03ad 100644 --- a/test/components/connect.spec.tsx +++ b/test/components/connect.spec.tsx @@ -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, diff --git a/test/integration/ssr.spec.tsx b/test/integration/ssr.spec.tsx new file mode 100644 index 000000000..e7c437d64 --- /dev/null +++ b/test/integration/ssr.spec.tsx @@ -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) { + 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 ( + + ) + } + + const mapState = (state: State) => ({ + count: selectCount(state), + }) + + const gcbConnector = connect(mapState) + type PropsFromRedux = ConnectedProps + + function GlobalCountButtonConnect({ count, dispatch }: PropsFromRedux) { + const isHydrated = useIsHydrated() + + return ( + + ) + } + + const ConnectedGlobalCountButtonConnect = gcbConnector( + GlobalCountButtonConnect + ) + + function App() { + return ( +
+ }> + + + +
+ ) + } + + const Spinner = () =>
+ + 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( + + + + ) + + // 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, + + + + ) + }) + + 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, + + + + ) + }) + + 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() + }) +})