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

fix: Ensure hydration matches the SSR result during streaming #2391

Merged
merged 3 commits into from
Jan 28, 2023
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
20 changes: 19 additions & 1 deletion _internal/utils/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SWRGlobalState } from './global-state'
import type { Cache, State, GlobalState } from '../types'

const EMPTY_CACHE = {}
const INITIAL_CACHE: Record<string, any> = {}
export const noop = () => {}

// Using noop() as the undefined value as undefined can be replaced
Expand Down Expand Up @@ -40,10 +41,27 @@ export const createCacheHelper = <Data = any, T = State<Data, any>>(
(info: T) => {
if (!isUndefined(key)) {
const prev = cache.get(key)

// Before writing to the store, we keep the value in the initial cache
// if it's not there yet.
if (!(key in INITIAL_CACHE)) {
INITIAL_CACHE[key] = prev
}

state[5](key, mergeObjects(prev, info), prev || EMPTY_CACHE)
}
},
// Subscriber
state[6]
state[6],
// Get server cache snapshot
() => {
if (!isUndefined(key)) {
// If the cache was updated on the client, we return the stored initial value.
if (key in INITIAL_CACHE) return INITIAL_CACHE[key]
}

// If we haven't done any client-side updates, we return the current value.
return (cache.get(key) || EMPTY_CACHE) as T
}
] as const
}
47 changes: 27 additions & 20 deletions core/use-swr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,14 @@ export const useSWRHandler = <Data = any, Error = any>(
const getConfig = () => configRef.current
const isActive = () => getConfig().isVisible() && getConfig().isOnline()

const [getCache, setCache, subscribeCache] = createCacheHelper<
Data,
State<Data, any> & {
// The original key arguments.
_k?: Key
}
>(cache, key)
const [getCache, setCache, subscribeCache, getInitialCache] =
createCacheHelper<
Data,
State<Data, any> & {
// The original key arguments.
_k?: Key
}
>(cache, key)

const stateDependencies = useRef<StateDependencies>({}).current

Expand Down Expand Up @@ -142,9 +143,8 @@ export const useSWRHandler = <Data = any, Error = any>(
return true
})()

const getSelectedCache = () => {
const state = getCache()

// Get the cache and merge it with expected states.
const getSelectedCache = (state: ReturnType<typeof getCache>) => {
// We only select the needed fields from the state.
const snapshot = mergeObjects(state)
delete snapshot._k
Expand All @@ -160,14 +160,21 @@ export const useSWRHandler = <Data = any, Error = any>(
}
}

let memorizedSnapshot = getSelectedCache()

return () => {
const snapshot = getSelectedCache()
return isEqual(snapshot, memorizedSnapshot)
? memorizedSnapshot
: (memorizedSnapshot = snapshot)
}
// To make sure that we are returning the same object reference to avoid
// unnecessary re-renders, we keep the previous snapshot and use deep
// comparison to check if we need to return a new one.
let memorizedSnapshot = getSelectedCache(getCache())
const memorizedInitialSnapshot = getSelectedCache(getInitialCache())

return [
() => {
const newSnapshot = getSelectedCache(getCache())
return isEqual(newSnapshot, memorizedSnapshot)
? memorizedSnapshot
: (memorizedSnapshot = newSnapshot)
},
() => memorizedInitialSnapshot
]
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cache, key])

Expand All @@ -184,8 +191,8 @@ export const useSWRHandler = <Data = any, Error = any>(
// eslint-disable-next-line react-hooks/exhaustive-deps
[cache, key]
),
getSnapshot,
getSnapshot
getSnapshot[0],
getSnapshot[1]
)

const isInitialMount = !initialMountedRef.current
Expand Down
84 changes: 84 additions & 0 deletions test/use-swr-streaming-ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { Suspense } from 'react'
promer94 marked this conversation as resolved.
Show resolved Hide resolved
import useSWR from 'swr'
import {
createKey,
createResponse,
renderWithConfig,
hydrateWithConfig,
mockConsoleForHydrationErrors,
sleep
} from './utils'

describe('useSWR - streaming', () => {
afterEach(() => {
jest.clearAllMocks()
jest.restoreAllMocks()
})

it('should match ssr result when hydrating', async () => {
const ensureAndUnmock = mockConsoleForHydrationErrors()

const key = createKey()

// A block fetches the data and updates the cache.
function Block() {
const { data } = useSWR(key, () => createResponse('SWR', { delay: 10 }))
return <div>{data || 'undefined'}</div>
}

const container = document.createElement('div')
container.innerHTML = '<div>undefined</div>'
await hydrateWithConfig(<Block />, container)
ensureAndUnmock()
})

// NOTE: this test is failing because it's not possible to test this behavior
// in JSDOM. We need to test this in a real browser.
it.failing(
'should match the ssr result when streaming and partially hydrating',
async () => {
const key = createKey()

const dataDuringHydration = {}

// A block fetches the data and updates the cache.
function Block({ suspense, delay, id }) {
const { data } = useSWR(key, () => createResponse('SWR', { delay }), {
suspense
})

// The first render is always hydration in our case.
if (!dataDuringHydration[id]) {
dataDuringHydration[id] = data || 'undefined'
}

return <div>{data || 'undefined'}</div>
}

// In this example, a will be hydrated first and b will still be streamed.
// When a is hydrated, it will update the client cache to SWR, and when
// b is being hydrated, it should NOT read that cache.
renderWithConfig(
<>
<Block id="a" suspense={false} delay={10} />
<Suspense fallback={null}>
<Block id="b" suspense={true} delay={20} />
</Suspense>
</>
)

// The SSR result will always be 2 undefined values because data fetching won't
// happen on the server:
// <div>undefined</div>
// <div>undefined</div>

// Wait for streaming to finish.
await sleep(50)

expect(dataDuringHydration).toEqual({
a: 'undefined',
b: 'undefined'
})
}
)
})
35 changes: 35 additions & 0 deletions test/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ export const renderWithGlobalCache = (
return _renderWithConfig(element, { ...config })
}

export const hydrateWithConfig = (
element: React.ReactElement,
container: HTMLElement,
config?: Parameters<typeof _renderWithConfig>[1]
): ReturnType<typeof _renderWithConfig> => {
const provider = () => new Map()
const TestSWRConfig = ({ children }: { children: React.ReactNode }) => (
<SWRConfig value={{ provider, ...config }}>{children}</SWRConfig>
)
return render(element, { container, wrapper: TestSWRConfig, hydrate: true })
}

export const mockVisibilityHidden = () => {
const mockVisibilityState = jest.spyOn(document, 'visibilityState', 'get')
mockVisibilityState.mockImplementation(() => 'hidden')
Expand All @@ -68,3 +80,26 @@ export async function executeWithoutBatching(fn: () => any) {
await fn()
global.IS_REACT_ACT_ENVIRONMENT = prev
}

export const mockConsoleForHydrationErrors = () => {
jest.spyOn(console, 'error').mockImplementation(() => {})
return () => {
// It should not have any hydration warnings.
expect(
// @ts-expect-error
console.error.mock.calls.find(([err]) => {
return (
err?.message?.includes(
'Text content does not match server-rendered HTML.'
) ||
err?.message?.includes(
'Hydration failed because the initial UI does not match what was rendered on the server.'
)
)
})
).toBeFalsy()

// @ts-expect-error
console.error.mockRestore()
}
}