Skip to content

Commit

Permalink
fix(useMedia): enhance SSR support by setting state during component …
Browse files Browse the repository at this point in the history
…mount (#2774)

To avoid rehydration disturbance. The return values like `isSmall` will
now always be false to start with.
This should not be a breaking change, because on the client, it results
as before.

I have not tested this hook IRL. Maybe someone can do that for me?
  • Loading branch information
tujoworker authored Oct 20, 2023
1 parent 2ad2c54 commit d72aa2b
Show file tree
Hide file tree
Showing 3 changed files with 168 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,82 @@ Numeric values will be handled as an `em` unit.

### `useMedia` hook usage

```js
```tsx
import { useMedia } from '@dnb/eufemia/shared'

function Component() {
const { isSmall, isMedium, isLarge, isSSR } = useMedia()

return isSmall ? 'true' : 'false'
return isSmall && <IsVisibleWhenSmall />
}
```

To lower the possibility of CLS (Cumulative Layout Shift) on larger screens – you can make use of the `isSSR` property. Try to use it in combination with `isLarge`, because the negative CLS experience is most recognizable on larger screens:

```js
```tsx
import { useMedia } from '@dnb/eufemia/shared'

function Component() {
const { isSmall, isMedium, isLarge, isSSR } = useMedia()

return isLarge || isSSR ? 'true' : 'false'
return (isLarge || isSSR) && <IsVisibleDuringSsrAndWhenLarge />
}
```

During SSR, when no `window` object is available, all results are negative. But you can provide a `initialValue`:

```tsx
import { useMedia } from '@dnb/eufemia/shared'

function Component() {
const { isSmall } = useMedia({
initialValue: {
isSmall: true,
},
})

return isSmall && <IsVisibleDuringSSR />
}
```

Here are all the options:

```tsx
import { useMedia } from '@dnb/eufemia/shared'

function Component() {
const { isSmall } = useMedia({
/**
* Give a initial value, that is used during SSR as well.
* Default: null
*/
initialValue?: Partial<UseMediaResult>

/**
* If set to true, no MediaQuery will be used.
* Default: false
*/
disabled?: boolean

/**
* Provide a custom breakpoint
* Default: defaultBreakpoints
*/
breakpoints?: MediaQueryBreakpoints

/**
* Provide a custom query
* Default: defaultQueries
*/
queries?: Record<string, MediaQueryCondition>

/**
* For debugging
*/
log?: boolean
})

return isSmall
}
```

Expand Down
69 changes: 68 additions & 1 deletion packages/dnb-eufemia/src/shared/__tests__/useMedia.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ describe('useMedia', () => {
})
)

expect(count).toBe(24)
expect(count).toBe(28)
})

it('will return correct key based on size', async () => {
Expand Down Expand Up @@ -880,6 +880,73 @@ describe('useMedia', () => {
)
})
})

describe('ssr', () => {
beforeAll(() => {
global.window['__SSR_TEST__'] = true
})

afterAll(() => {
delete global.window['__SSR_TEST__']
})

it('will by default return false on all sizes', () => {
const { result } = renderHook(useMedia, { wrapper })

expect(result.current).toEqual(
expect.objectContaining({
isSSR: true,
isSmall: false,
isMedium: false,
isLarge: false,
key: null,
})
)
})

it('will return positive isSmall when in initialValue', () => {
const { result } = renderHook(useMedia, {
wrapper,
initialProps: {
initialValue: {
isSmall: true,
},
},
})

expect(result.current).toEqual(
expect.objectContaining({
isSSR: true,
isSmall: true,
isMedium: false,
isLarge: false,
key: 'small',
})
)
})

it('will return both positive isSmall and isLarge', () => {
const { result } = renderHook(useMedia, {
wrapper,
initialProps: {
initialValue: {
isSmall: true,
isLarge: true,
},
},
})

expect(result.current).toEqual(
expect.objectContaining({
isSSR: true,
isSmall: true,
isMedium: false,
isLarge: true,
key: 'large',
})
)
})
})
})

describe('useMedia without window.matchMedia', () => {
Expand Down
51 changes: 39 additions & 12 deletions packages/dnb-eufemia/src/shared/useMedia.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,22 @@ import type {
} from './MediaQueryUtils'
import { toPascalCase } from './component-helper'

const makeLayoutEffect = () => {
// SSR warning fix: https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
return typeof window === 'undefined'
? React.useEffect
: window['__SSR_TEST__'] // To be able to test this hook like we are in SSR land
? () => null
: React.useLayoutEffect
}

export type UseMediaProps = {
/**
* Give a initial value, that is used during SSR as well.
* Default: null
*/
initialValue?: Partial<UseMediaResult>

/**
* If set to true, no MediaQuery will be used.
* Default: false
Expand Down Expand Up @@ -78,32 +93,40 @@ type UseMediaQueryProps = {
export default function useMedia(
props: UseMediaProps = {}
): UseMediaResult {
const { disabled, breakpoints, queries = defaultQueries, log } = props
const {
initialValue = null,
disabled,
breakpoints,
queries = defaultQueries,
log,
} = props

const context = React.useContext(Context)

const refs = React.useRef({})
const defaults = React.useRef({})
const disabledRef = React.useRef(disabled)
const isMounted = React.useRef(false)
const isDisabled = React.useRef(disabled)
const [result, updateRerender] =
React.useState<UseMediaResult>(makeResult)

React.useEffect(() => {
// In StrictMode, the keys got empty,
// so we make the result again
if (Object.keys(refs.current).length) {
makeResult()
const useLayoutEffect = React.useMemo(makeLayoutEffect, [])

useLayoutEffect(() => {
if (!isMounted.current) {
isMounted.current = true
updateRerender(makeResult())
}

return removeListeners
}, []) // eslint-disable-line react-hooks/exhaustive-deps

React.useEffect(() => {
useLayoutEffect(() => {
// If it was disabled before
if (disabledRef.current && !disabled) {
if (isDisabled.current && !disabled) {
updateRerender(makeResult())
}
disabledRef.current = disabled
isDisabled.current = disabled
}, [disabled]) // eslint-disable-line react-hooks/exhaustive-deps

return result
Expand Down Expand Up @@ -136,7 +159,11 @@ export default function useMedia(
key,
})

const hasMatch = item?.mediaQueryList?.matches || false
const hasMatch = !isMounted.current
? typeof initialValue?.[name] !== 'undefined'
? initialValue[name]
: false
: item?.mediaQueryList?.matches || false
acc[name] = hasMatch
if (hasMatch) {
acc.key = key
Expand Down Expand Up @@ -170,7 +197,7 @@ export default function useMedia(
)

const event = createMediaQueryListener(mediaQueryList, (match) => {
if (!disabledRef.current && match) {
if (!isDisabled.current && match) {
const state = {
...defaults.current,
key,
Expand Down

0 comments on commit d72aa2b

Please sign in to comment.