Skip to content

Commit

Permalink
feat(useSSRWidth): add optional support for SSR in useMediaQuery and …
Browse files Browse the repository at this point in the history
…useBreakpoints (#4317)

Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
Co-authored-by: Anthony Fu <github@antfu.me>
  • Loading branch information
3 people authored Dec 22, 2024
1 parent df363a3 commit 559653c
Show file tree
Hide file tree
Showing 11 changed files with 309 additions and 34 deletions.
1 change: 1 addition & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export * from './useShare'
export * from './useSorted'
export * from './useSpeechRecognition'
export * from './useSpeechSynthesis'
export * from './useSSRWidth'
export * from './useStepper'
export * from './useStorage'
export * from './useStorageAsync'
Expand Down
15 changes: 15 additions & 0 deletions packages/core/useBreakpoints/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ const laptop = breakpoints.between('laptop', 'desktop')
</template>
```

#### Server Side Rendering and Nuxt

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

```js
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'

const breakpoints = useBreakpoints(
breakpointsTailwind,
{ ssrWidth: 768 } // Will enable SSR mode and render like if the screen was 768px wide
)
```

Alternatively you can set this up globally for your app using [`provideSSRWidth`](../useSSRWidth/index.md)

## Presets

- Tailwind: `breakpointsTailwind`
Expand Down
55 changes: 55 additions & 0 deletions packages/core/useBreakpoints/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { provideSSRWidth } from '@vueuse/core'
import { describe, expect, it } from 'vitest'
import { createSSRApp } from 'vue'
import { breakpointsBootstrapV5, useBreakpoints } from '.'

describe('useBreakpoints', () => {
it('should be defined', () => {
expect(useBreakpoints).toBeDefined()
})

it('should support ssr breakpoints', async () => {
const breakpoints = useBreakpoints(breakpointsBootstrapV5, { window: null as unknown as undefined, ssrWidth: 768 })
expect(breakpoints.current().value).toStrictEqual(['xs', 'sm', 'md'])
expect(breakpoints.active().value).toBe('md')
expect(breakpoints.isGreater('md')).toBe(false)
expect(breakpoints.isGreaterOrEqual('md')).toBe(true)
expect(breakpoints.isSmaller('md')).toBe(false)
expect(breakpoints.isSmallerOrEqual('md')).toBe(true)
expect(breakpoints.isInBetween('md', 'lg')).toBe(true)
expect(breakpoints.isInBetween('sm', 'md')).toBe(false)
expect(breakpoints.md.value).toBe(true)
expect(breakpoints.lg.value).toBe(false)
expect(breakpoints.sm.value).toBe(true)
})

it('should support max-width strategy', async () => {
const breakpoints = useBreakpoints({
xl: 1399,
lg: 1199,
md: 991,
sm: 767,
xs: 575,
}, { strategy: 'max-width', window: null as unknown as undefined, ssrWidth: 768 })
expect(breakpoints.current().value).toStrictEqual(['md', 'lg', 'xl'])
expect(breakpoints.active().value).toBe('md')
expect(breakpoints.isGreater('md')).toBe(false)
expect(breakpoints.isGreaterOrEqual('sm')).toBe(true)
expect(breakpoints.isSmaller('md')).toBe(true)
expect(breakpoints.isSmallerOrEqual('sm')).toBe(false)
expect(breakpoints.isInBetween('md', 'lg')).toBe(false)
expect(breakpoints.isInBetween('sm', 'md')).toBe(true)
expect(breakpoints.md.value).toBe(true)
expect(breakpoints.lg.value).toBe(true)
expect(breakpoints.sm.value).toBe(false)
})

it('should get the ssr width from the global store', async () => {
const app = createSSRApp({ render: () => '' })
provideSSRWidth(768, app)
await app.runWithContext(async () => {
const breakpoints = useBreakpoints(breakpointsBootstrapV5, { window: null as unknown as undefined })
expect(breakpoints.current().value).toStrictEqual(['xs', 'sm', 'md'])
})
})
})
61 changes: 30 additions & 31 deletions packages/core/useBreakpoints/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { MaybeRefOrGetter } from '@vueuse/shared'
import type { ComputedRef, Ref } from 'vue'
import type { ConfigurableWindow } from '../_configurable'
import { increaseWithUnit, toValue } from '@vueuse/shared'
import { computed } from 'vue'
import { increaseWithUnit, pxValue, toValue, tryOnMounted } from '@vueuse/shared'
import { computed, ref } from 'vue'
import { defaultWindow } from '../_configurable'
import { useMediaQuery } from '../useMediaQuery'
import { useSSRWidth } from '../useSSRWidth'

export * from './breakpoints'

Expand All @@ -20,6 +20,7 @@ export interface UseBreakpointsOptions extends ConfigurableWindow {
* @default "min-width"
*/
strategy?: 'min-width' | 'max-width'
ssrWidth?: number
}

/**
Expand All @@ -43,12 +44,21 @@ export function useBreakpoints<K extends string>(
return v
}

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

function match(query: string): boolean {
const ssrSupport = typeof ssrWidth === 'number'
const mounted = ssrSupport ? ref(false) : { value: true }
if (ssrSupport) {
tryOnMounted(() => mounted.value = !!window)
}

function match(query: 'min' | 'max', size: string): boolean {
if (!mounted.value && ssrSupport) {
return query === 'min' ? ssrWidth >= pxValue(size) : ssrWidth <= pxValue(size)
}
if (!window)
return false
return window.matchMedia(query).matches
return window.matchMedia(`(${query}-width: ${size})`).matches
}

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

const shortcutMethods = Object.keys(breakpoints)
const shortcutMethods = (Object.keys(breakpoints) as K[])
.reduce((shortcuts, k) => {
Object.defineProperty(shortcuts, k, {
get: () => strategy === 'min-width'
? greaterOrEqual(k as K)
: smallerOrEqual(k as K),
? greaterOrEqual(k)
: smallerOrEqual(k),
enumerable: true,
configurable: true,
})
return shortcuts
}, {} as Record<K, Ref<boolean>>)
}, {} as Record<K, ReturnType<typeof greaterOrEqual>>)

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

Expand All @@ -89,39 +101,26 @@ export function useBreakpoints<K extends string>(
return useMediaQuery(() => `(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`, options)
},
isGreater(k: MaybeRefOrGetter<K>) {
return match(`(min-width: ${getValue(k, 0.1)})`)
return match('min', getValue(k, 0.1))
},
isGreaterOrEqual(k: MaybeRefOrGetter<K>) {
return match(`(min-width: ${getValue(k)})`)
return match('min', getValue(k))
},
isSmaller(k: MaybeRefOrGetter<K>) {
return match(`(max-width: ${getValue(k, -0.1)})`)
return match('max', getValue(k, -0.1))
},
isSmallerOrEqual(k: MaybeRefOrGetter<K>) {
return match(`(max-width: ${getValue(k)})`)
return match('max', getValue(k))
},
isInBetween(a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) {
return match(`(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`)
return match('min', getValue(a)) && match('max', getValue(b, -0.1))
},
current,
active() {
const bps = current()
return computed(() => bps.value.length === 0 ? '' : bps.value.at(-1))
return computed(() => bps.value.length === 0 ? '' : bps.value.at(strategy === 'min-width' ? -1 : 0)!)
},
})
}

export type UseBreakpointsReturn<K extends string = string> = {
greater: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>
greaterOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>
smaller: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>
smallerOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>
between: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => ComputedRef<boolean>
isGreater: (k: MaybeRefOrGetter<K>) => boolean
isGreaterOrEqual: (k: MaybeRefOrGetter<K>) => boolean
isSmaller: (k: MaybeRefOrGetter<K>) => boolean
isSmallerOrEqual: (k: MaybeRefOrGetter<K>) => boolean
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => boolean
current: () => ComputedRef<string[]>
active: ComputedRef<string>
} & Record<K, ComputedRef<boolean>>
export type UseBreakpointsReturn<K extends string = string> = ReturnType<typeof useBreakpoints<K>>
20 changes: 20 additions & 0 deletions packages/core/useMediaQuery/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,23 @@ import { useMediaQuery } from '@vueuse/core'
const isLargeScreen = useMediaQuery('(min-width: 1024px)')
const isPreferredDark = useMediaQuery('(prefers-color-scheme: dark)')
```

#### Server Side Rendering and Nuxt

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

```js
import { breakpointsTailwind, useMediaQuery } from '@vueuse/core'

const isLarge = useMediaQuery(
'(min-width: 1024px)',
{ ssrWidth: 768 } // Will enable SSR mode and render like if the screen was 768px wide
)

console.log(isLarge.value) // always false because ssrWidth of 768px is smaller than 1024px
onMounted(() => {
console.log(isLarge.value) // false if screen is smaller than 1024px, true if larger than 1024px
})
```

Alternatively you can set this up globally for your app using [`provideSSRWidth`](../useSSRWidth/index.md)
60 changes: 60 additions & 0 deletions packages/core/useMediaQuery/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { createSSRApp, nextTick, ref } from 'vue'
import { useMediaQuery } from '.'
import { provideSSRWidth } from '../useSSRWidth'

describe('useMediaQuery', () => {
it('should be defined', () => {
expect(useMediaQuery).toBeDefined()
})

it('should be false without window', () => {
expect(useMediaQuery('(min-width: 0px)', { window: null as unknown as undefined }).value).toBe(false)
})

it('should support ssr media queries', async () => {
const query = ref('(min-width: 500px)')
const mediaQuery = useMediaQuery(query, { window: null as unknown as undefined, ssrWidth: 500 })
expect(mediaQuery.value).toBe(true)
query.value = '(min-width: 501px)'
await nextTick()
expect(mediaQuery.value).toBe(false)

query.value = '(min-width: 500px) and (max-width: 37rem)'
await nextTick()
expect(mediaQuery.value).toBe(true)

query.value = '(max-width: 31rem)'
await nextTick()
expect(mediaQuery.value).toBe(false)

query.value = '(max-width: 31rem), (min-width: 400px)'
await nextTick()
expect(mediaQuery.value).toBe(true)

query.value = '(max-width: 31rem), not all and (min-width: 400px)'
await nextTick()
expect(mediaQuery.value).toBe(false)

query.value = 'not all (min-width: 400px) and (max-width: 600px)'
await nextTick()
expect(mediaQuery.value).toBe(false)

query.value = 'not all (max-width: 100px) and (min-width: 1000px)'
await nextTick()
expect(mediaQuery.value).toBe(true)
})

it('should get the ssr width from the global store', async () => {
const app = createSSRApp({ render: () => '' })
provideSSRWidth(500, app)
await app.runWithContext(async () => {
const query = ref('(min-width: 500px)')
const mediaQuery = useMediaQuery(query, { window: null as unknown as undefined })
expect(mediaQuery.value).toBe(true)
query.value = '(min-width: 501px)'
await nextTick()
expect(mediaQuery.value).toBe(false)
})
})
})
29 changes: 26 additions & 3 deletions packages/core/useMediaQuery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import type { MaybeRefOrGetter } from '@vueuse/shared'
import type { ConfigurableWindow } from '../_configurable'
import { toValue, tryOnScopeDispose } from '@vueuse/shared'
import { pxValue, toValue, tryOnScopeDispose } from '@vueuse/shared'
import { computed, ref, watchEffect } from 'vue'
import { defaultWindow } from '../_configurable'
import { useSSRWidth } from '../useSSRWidth'
import { useSupported } from '../useSupported'

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

const ssrSupport = ref(typeof ssrWidth === 'number')

let mediaQuery: MediaQueryList | undefined
const matches = ref(false)

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

const stopWatch = watchEffect(() => {
if (ssrSupport.value) {
// Exit SSR support on mounted if window available
ssrSupport.value = !isSupported.value

const queryStrings = toValue(query).split(',')
matches.value = queryStrings.some((queryString) => {
const not = queryString.includes('not all')
const minWidth = queryString.match(/\(\s*min-width:\s*(-?\d+(?:\.\d*)?[a-z]+\s*)\)/)
const maxWidth = queryString.match(/\(\s*max-width:\s*(-?\d+(?:\.\d*)?[a-z]+\s*)\)/)
let res = Boolean(minWidth || maxWidth)
if (minWidth && res) {
res = ssrWidth! >= pxValue(minWidth[1])
}
if (maxWidth && res) {
res = ssrWidth! <= pxValue(maxWidth[1])
}
return not ? !res : res
})
return
}
if (!isSupported.value)
return

Expand Down
37 changes: 37 additions & 0 deletions packages/core/useSSRWidth/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
category: Browser
---

# useSSRWidth

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)

## Usage

```js
import { provideSSRWidth } from '@vueuse/core'

const app = createApp(App)

provideSSRWidth(500, app)
```

Or in the root component

```vue
<script setup>
import { provideSSRWidth } from '@vueuse/core'
provideSSRWidth(500)
</script>
```

To retrieve the provided value if you need it in a subcomponent

```vue
<script setup>
import { useSSRWidth } from '@vueuse/core'
const width = useSSRWidth()
</script>
```
Loading

0 comments on commit 559653c

Please sign in to comment.