Skip to content

Commit 559653c

Browse files
Tofandelantfu
andauthored
feat(useSSRWidth): add optional support for SSR in useMediaQuery and useBreakpoints (#4317)
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com> Co-authored-by: Anthony Fu <github@antfu.me>
1 parent df363a3 commit 559653c

File tree

11 files changed

+309
-34
lines changed

11 files changed

+309
-34
lines changed

packages/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export * from './useShare'
107107
export * from './useSorted'
108108
export * from './useSpeechRecognition'
109109
export * from './useSpeechSynthesis'
110+
export * from './useSSRWidth'
110111
export * from './useStepper'
111112
export * from './useStorage'
112113
export * from './useStorageAsync'

packages/core/useBreakpoints/index.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ const laptop = breakpoints.between('laptop', 'desktop')
4444
</template>
4545
```
4646

47+
#### Server Side Rendering and Nuxt
48+
49+
If you are using `useBreakpoints` with SSR enabled, then you need to specify which screen size you would like to render on the server and before hydration to avoid an hydration mismatch
50+
51+
```js
52+
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
53+
54+
const breakpoints = useBreakpoints(
55+
breakpointsTailwind,
56+
{ ssrWidth: 768 } // Will enable SSR mode and render like if the screen was 768px wide
57+
)
58+
```
59+
60+
Alternatively you can set this up globally for your app using [`provideSSRWidth`](../useSSRWidth/index.md)
61+
4762
## Presets
4863

4964
- Tailwind: `breakpointsTailwind`
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { provideSSRWidth } from '@vueuse/core'
2+
import { describe, expect, it } from 'vitest'
3+
import { createSSRApp } from 'vue'
4+
import { breakpointsBootstrapV5, useBreakpoints } from '.'
5+
6+
describe('useBreakpoints', () => {
7+
it('should be defined', () => {
8+
expect(useBreakpoints).toBeDefined()
9+
})
10+
11+
it('should support ssr breakpoints', async () => {
12+
const breakpoints = useBreakpoints(breakpointsBootstrapV5, { window: null as unknown as undefined, ssrWidth: 768 })
13+
expect(breakpoints.current().value).toStrictEqual(['xs', 'sm', 'md'])
14+
expect(breakpoints.active().value).toBe('md')
15+
expect(breakpoints.isGreater('md')).toBe(false)
16+
expect(breakpoints.isGreaterOrEqual('md')).toBe(true)
17+
expect(breakpoints.isSmaller('md')).toBe(false)
18+
expect(breakpoints.isSmallerOrEqual('md')).toBe(true)
19+
expect(breakpoints.isInBetween('md', 'lg')).toBe(true)
20+
expect(breakpoints.isInBetween('sm', 'md')).toBe(false)
21+
expect(breakpoints.md.value).toBe(true)
22+
expect(breakpoints.lg.value).toBe(false)
23+
expect(breakpoints.sm.value).toBe(true)
24+
})
25+
26+
it('should support max-width strategy', async () => {
27+
const breakpoints = useBreakpoints({
28+
xl: 1399,
29+
lg: 1199,
30+
md: 991,
31+
sm: 767,
32+
xs: 575,
33+
}, { strategy: 'max-width', window: null as unknown as undefined, ssrWidth: 768 })
34+
expect(breakpoints.current().value).toStrictEqual(['md', 'lg', 'xl'])
35+
expect(breakpoints.active().value).toBe('md')
36+
expect(breakpoints.isGreater('md')).toBe(false)
37+
expect(breakpoints.isGreaterOrEqual('sm')).toBe(true)
38+
expect(breakpoints.isSmaller('md')).toBe(true)
39+
expect(breakpoints.isSmallerOrEqual('sm')).toBe(false)
40+
expect(breakpoints.isInBetween('md', 'lg')).toBe(false)
41+
expect(breakpoints.isInBetween('sm', 'md')).toBe(true)
42+
expect(breakpoints.md.value).toBe(true)
43+
expect(breakpoints.lg.value).toBe(true)
44+
expect(breakpoints.sm.value).toBe(false)
45+
})
46+
47+
it('should get the ssr width from the global store', async () => {
48+
const app = createSSRApp({ render: () => '' })
49+
provideSSRWidth(768, app)
50+
await app.runWithContext(async () => {
51+
const breakpoints = useBreakpoints(breakpointsBootstrapV5, { window: null as unknown as undefined })
52+
expect(breakpoints.current().value).toStrictEqual(['xs', 'sm', 'md'])
53+
})
54+
})
55+
})

packages/core/useBreakpoints/index.ts

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { MaybeRefOrGetter } from '@vueuse/shared'
2-
import type { ComputedRef, Ref } from 'vue'
32
import type { ConfigurableWindow } from '../_configurable'
4-
import { increaseWithUnit, toValue } from '@vueuse/shared'
5-
import { computed } from 'vue'
3+
import { increaseWithUnit, pxValue, toValue, tryOnMounted } from '@vueuse/shared'
4+
import { computed, ref } from 'vue'
65
import { defaultWindow } from '../_configurable'
76
import { useMediaQuery } from '../useMediaQuery'
7+
import { useSSRWidth } from '../useSSRWidth'
88

99
export * from './breakpoints'
1010

@@ -20,6 +20,7 @@ export interface UseBreakpointsOptions extends ConfigurableWindow {
2020
* @default "min-width"
2121
*/
2222
strategy?: 'min-width' | 'max-width'
23+
ssrWidth?: number
2324
}
2425

2526
/**
@@ -43,12 +44,21 @@ export function useBreakpoints<K extends string>(
4344
return v
4445
}
4546

46-
const { window = defaultWindow, strategy = 'min-width' } = options
47+
const { window = defaultWindow, strategy = 'min-width', ssrWidth = useSSRWidth() } = options
4748

48-
function match(query: string): boolean {
49+
const ssrSupport = typeof ssrWidth === 'number'
50+
const mounted = ssrSupport ? ref(false) : { value: true }
51+
if (ssrSupport) {
52+
tryOnMounted(() => mounted.value = !!window)
53+
}
54+
55+
function match(query: 'min' | 'max', size: string): boolean {
56+
if (!mounted.value && ssrSupport) {
57+
return query === 'min' ? ssrWidth >= pxValue(size) : ssrWidth <= pxValue(size)
58+
}
4959
if (!window)
5060
return false
51-
return window.matchMedia(query).matches
61+
return window.matchMedia(`(${query}-width: ${size})`).matches
5262
}
5363

5464
const greaterOrEqual = (k: MaybeRefOrGetter<K>) => {
@@ -59,20 +69,22 @@ export function useBreakpoints<K extends string>(
5969
return useMediaQuery(() => `(max-width: ${getValue(k)})`, options)
6070
}
6171

62-
const shortcutMethods = Object.keys(breakpoints)
72+
const shortcutMethods = (Object.keys(breakpoints) as K[])
6373
.reduce((shortcuts, k) => {
6474
Object.defineProperty(shortcuts, k, {
6575
get: () => strategy === 'min-width'
66-
? greaterOrEqual(k as K)
67-
: smallerOrEqual(k as K),
76+
? greaterOrEqual(k)
77+
: smallerOrEqual(k),
6878
enumerable: true,
6979
configurable: true,
7080
})
7181
return shortcuts
72-
}, {} as Record<K, Ref<boolean>>)
82+
}, {} as Record<K, ReturnType<typeof greaterOrEqual>>)
7383

7484
function current() {
75-
const points = Object.keys(breakpoints).map(i => [i, greaterOrEqual(i as K)] as const)
85+
const points = (Object.keys(breakpoints) as K[])
86+
.map(k => [k, shortcutMethods[k], pxValue(getValue(k))] as const)
87+
.sort((a, b) => a[2] - b[2])
7688
return computed(() => points.filter(([, v]) => v.value).map(([k]) => k))
7789
}
7890

@@ -89,39 +101,26 @@ export function useBreakpoints<K extends string>(
89101
return useMediaQuery(() => `(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`, options)
90102
},
91103
isGreater(k: MaybeRefOrGetter<K>) {
92-
return match(`(min-width: ${getValue(k, 0.1)})`)
104+
return match('min', getValue(k, 0.1))
93105
},
94106
isGreaterOrEqual(k: MaybeRefOrGetter<K>) {
95-
return match(`(min-width: ${getValue(k)})`)
107+
return match('min', getValue(k))
96108
},
97109
isSmaller(k: MaybeRefOrGetter<K>) {
98-
return match(`(max-width: ${getValue(k, -0.1)})`)
110+
return match('max', getValue(k, -0.1))
99111
},
100112
isSmallerOrEqual(k: MaybeRefOrGetter<K>) {
101-
return match(`(max-width: ${getValue(k)})`)
113+
return match('max', getValue(k))
102114
},
103115
isInBetween(a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) {
104-
return match(`(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`)
116+
return match('min', getValue(a)) && match('max', getValue(b, -0.1))
105117
},
106118
current,
107119
active() {
108120
const bps = current()
109-
return computed(() => bps.value.length === 0 ? '' : bps.value.at(-1))
121+
return computed(() => bps.value.length === 0 ? '' : bps.value.at(strategy === 'min-width' ? -1 : 0)!)
110122
},
111123
})
112124
}
113125

114-
export type UseBreakpointsReturn<K extends string = string> = {
115-
greater: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>
116-
greaterOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>
117-
smaller: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>
118-
smallerOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>
119-
between: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => ComputedRef<boolean>
120-
isGreater: (k: MaybeRefOrGetter<K>) => boolean
121-
isGreaterOrEqual: (k: MaybeRefOrGetter<K>) => boolean
122-
isSmaller: (k: MaybeRefOrGetter<K>) => boolean
123-
isSmallerOrEqual: (k: MaybeRefOrGetter<K>) => boolean
124-
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => boolean
125-
current: () => ComputedRef<string[]>
126-
active: ComputedRef<string>
127-
} & Record<K, ComputedRef<boolean>>
126+
export type UseBreakpointsReturn<K extends string = string> = ReturnType<typeof useBreakpoints<K>>

packages/core/useMediaQuery/index.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,23 @@ import { useMediaQuery } from '@vueuse/core'
1414
const isLargeScreen = useMediaQuery('(min-width: 1024px)')
1515
const isPreferredDark = useMediaQuery('(prefers-color-scheme: dark)')
1616
```
17+
18+
#### Server Side Rendering and Nuxt
19+
20+
If you are using `useMediaQuery` with SSR enabled, then you need to specify which screen size you would like to render on the server and before hydration to avoid an hydration mismatch
21+
22+
```js
23+
import { breakpointsTailwind, useMediaQuery } from '@vueuse/core'
24+
25+
const isLarge = useMediaQuery(
26+
'(min-width: 1024px)',
27+
{ ssrWidth: 768 } // Will enable SSR mode and render like if the screen was 768px wide
28+
)
29+
30+
console.log(isLarge.value) // always false because ssrWidth of 768px is smaller than 1024px
31+
onMounted(() => {
32+
console.log(isLarge.value) // false if screen is smaller than 1024px, true if larger than 1024px
33+
})
34+
```
35+
36+
Alternatively you can set this up globally for your app using [`provideSSRWidth`](../useSSRWidth/index.md)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createSSRApp, nextTick, ref } from 'vue'
3+
import { useMediaQuery } from '.'
4+
import { provideSSRWidth } from '../useSSRWidth'
5+
6+
describe('useMediaQuery', () => {
7+
it('should be defined', () => {
8+
expect(useMediaQuery).toBeDefined()
9+
})
10+
11+
it('should be false without window', () => {
12+
expect(useMediaQuery('(min-width: 0px)', { window: null as unknown as undefined }).value).toBe(false)
13+
})
14+
15+
it('should support ssr media queries', async () => {
16+
const query = ref('(min-width: 500px)')
17+
const mediaQuery = useMediaQuery(query, { window: null as unknown as undefined, ssrWidth: 500 })
18+
expect(mediaQuery.value).toBe(true)
19+
query.value = '(min-width: 501px)'
20+
await nextTick()
21+
expect(mediaQuery.value).toBe(false)
22+
23+
query.value = '(min-width: 500px) and (max-width: 37rem)'
24+
await nextTick()
25+
expect(mediaQuery.value).toBe(true)
26+
27+
query.value = '(max-width: 31rem)'
28+
await nextTick()
29+
expect(mediaQuery.value).toBe(false)
30+
31+
query.value = '(max-width: 31rem), (min-width: 400px)'
32+
await nextTick()
33+
expect(mediaQuery.value).toBe(true)
34+
35+
query.value = '(max-width: 31rem), not all and (min-width: 400px)'
36+
await nextTick()
37+
expect(mediaQuery.value).toBe(false)
38+
39+
query.value = 'not all (min-width: 400px) and (max-width: 600px)'
40+
await nextTick()
41+
expect(mediaQuery.value).toBe(false)
42+
43+
query.value = 'not all (max-width: 100px) and (min-width: 1000px)'
44+
await nextTick()
45+
expect(mediaQuery.value).toBe(true)
46+
})
47+
48+
it('should get the ssr width from the global store', async () => {
49+
const app = createSSRApp({ render: () => '' })
50+
provideSSRWidth(500, app)
51+
await app.runWithContext(async () => {
52+
const query = ref('(min-width: 500px)')
53+
const mediaQuery = useMediaQuery(query, { window: null as unknown as undefined })
54+
expect(mediaQuery.value).toBe(true)
55+
query.value = '(min-width: 501px)'
56+
await nextTick()
57+
expect(mediaQuery.value).toBe(false)
58+
})
59+
})
60+
})

packages/core/useMediaQuery/index.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import type { MaybeRefOrGetter } from '@vueuse/shared'
44
import type { ConfigurableWindow } from '../_configurable'
5-
import { toValue, tryOnScopeDispose } from '@vueuse/shared'
5+
import { pxValue, toValue, tryOnScopeDispose } from '@vueuse/shared'
66
import { computed, ref, watchEffect } from 'vue'
77
import { defaultWindow } from '../_configurable'
8+
import { useSSRWidth } from '../useSSRWidth'
89
import { useSupported } from '../useSupported'
910

1011
/**
@@ -14,10 +15,12 @@ import { useSupported } from '../useSupported'
1415
* @param query
1516
* @param options
1617
*/
17-
export function useMediaQuery(query: MaybeRefOrGetter<string>, options: ConfigurableWindow = {}) {
18-
const { window = defaultWindow } = options
18+
export function useMediaQuery(query: MaybeRefOrGetter<string>, options: ConfigurableWindow & { ssrWidth?: number } = {}) {
19+
const { window = defaultWindow, ssrWidth = useSSRWidth() } = options
1920
const isSupported = useSupported(() => window && 'matchMedia' in window && typeof window.matchMedia === 'function')
2021

22+
const ssrSupport = ref(typeof ssrWidth === 'number')
23+
2124
let mediaQuery: MediaQueryList | undefined
2225
const matches = ref(false)
2326

@@ -36,6 +39,26 @@ export function useMediaQuery(query: MaybeRefOrGetter<string>, options: Configur
3639
}
3740

3841
const stopWatch = watchEffect(() => {
42+
if (ssrSupport.value) {
43+
// Exit SSR support on mounted if window available
44+
ssrSupport.value = !isSupported.value
45+
46+
const queryStrings = toValue(query).split(',')
47+
matches.value = queryStrings.some((queryString) => {
48+
const not = queryString.includes('not all')
49+
const minWidth = queryString.match(/\(\s*min-width:\s*(-?\d+(?:\.\d*)?[a-z]+\s*)\)/)
50+
const maxWidth = queryString.match(/\(\s*max-width:\s*(-?\d+(?:\.\d*)?[a-z]+\s*)\)/)
51+
let res = Boolean(minWidth || maxWidth)
52+
if (minWidth && res) {
53+
res = ssrWidth! >= pxValue(minWidth[1])
54+
}
55+
if (maxWidth && res) {
56+
res = ssrWidth! <= pxValue(maxWidth[1])
57+
}
58+
return not ? !res : res
59+
})
60+
return
61+
}
3962
if (!isSupported.value)
4063
return
4164

packages/core/useSSRWidth/index.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
category: Browser
3+
---
4+
5+
# useSSRWidth
6+
7+
Used to set a global viewport width which will be used when rendering SSR components that rely on the viewport width like [`useMediaQuery`](../useMediaQuery/index.md) or [`useBreakpoints`](../useBreakpoints/index.md)
8+
9+
## Usage
10+
11+
```js
12+
import { provideSSRWidth } from '@vueuse/core'
13+
14+
const app = createApp(App)
15+
16+
provideSSRWidth(500, app)
17+
```
18+
19+
Or in the root component
20+
21+
```vue
22+
<script setup>
23+
import { provideSSRWidth } from '@vueuse/core'
24+
25+
provideSSRWidth(500)
26+
</script>
27+
```
28+
29+
To retrieve the provided value if you need it in a subcomponent
30+
31+
```vue
32+
<script setup>
33+
import { useSSRWidth } from '@vueuse/core'
34+
35+
const width = useSSRWidth()
36+
</script>
37+
```

0 commit comments

Comments
 (0)