From e6d3d31bb4b50cadc0fe7cf9b8c63f59c82d2b88 Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 23 Sep 2026 11:13:39 -0500 Subject: [PATCH 1/5] refactor(display): delegate breakpoint math to createBreakpoints createDisplay still returns the instance components already read. Flag calculation now comes from @vuetify/v0 createBreakpoints, which follows matchMedia when the browser has it. Resize stays here. The v0 plugin subscribes by replacing app.mount, and createVuetify already owns that hook for the SSR flush. --- packages/vuetify/src/composables/display.ts | 111 ++++++++------------ packages/vuetify/src/util/v0.ts | 2 +- 2 files changed, 46 insertions(+), 67 deletions(-) diff --git a/packages/vuetify/src/composables/display.ts b/packages/vuetify/src/composables/display.ts index 6854832b34d..4f40af3e06f 100644 --- a/packages/vuetify/src/composables/display.ts +++ b/packages/vuetify/src/composables/display.ts @@ -1,6 +1,6 @@ // Utilities -import { computed, inject, onScopeDispose, reactive, shallowRef, toRef, toRefs, watchEffect } from 'vue' -import { getCurrentInstanceName, isNull, isNumber, isObject, mergeDeep, propsFactory } from '@/util' +import { computed, inject, onScopeDispose, shallowRef, toRef } from 'vue' +import { createBreakpoints, getCurrentInstanceName, isNull, isNumber, isObject, mergeDeep, propsFactory } from '@/util' import { IN_BROWSER, SUPPORTS_TOUCH } from '@/util/globals' // Types @@ -99,18 +99,6 @@ const parseDisplayOptions = (options: DisplayOptions = defaultDisplayOptions) => return mergeDeep(defaultDisplayOptions, options) as InternalDisplayOptions } -function getClientWidth (ssr?: SSROptions) { - return IN_BROWSER && !ssr - ? window.innerWidth - : (isObject(ssr) && ssr.clientWidth) || 0 -} - -function getClientHeight (ssr?: SSROptions) { - return IN_BROWSER && !ssr - ? window.innerHeight - : (isObject(ssr) && ssr.clientHeight) || 0 -} - function getPlatform (ssr?: SSROptions): DisplayPlatform { const userAgent = IN_BROWSER && !ssr ? window.navigator.userAgent @@ -152,70 +140,61 @@ function getPlatform (ssr?: SSROptions): DisplayPlatform { export function createDisplay (options?: DisplayOptions, ssr?: SSROptions): DisplayInstance { const { thresholds, mobileBreakpoint } = parseDisplayOptions(options) - const height = shallowRef(getClientHeight(ssr)) + // `ssr: true` is the boolean form; createBreakpoints only accepts a size. + const ssrSize = isObject(ssr) + ? ssr + : ssr + ? { clientWidth: 0, clientHeight: 0 } + : undefined + + const screen = createBreakpoints({ + breakpoints: thresholds, + mobileBreakpoint, + ssr: ssrSize, + }) + const platform = shallowRef(getPlatform(ssr)) - const state = reactive({} as DisplayInstance) - const width = shallowRef(getClientWidth(ssr)) - function updateSize () { - height.value = getClientHeight() - width.value = getClientWidth() - } function update () { - updateSize() + screen.update() platform.value = getPlatform() } - // eslint-disable-next-line max-statements - watchEffect(() => { - const xs = width.value < thresholds.sm - const sm = width.value < thresholds.md && !xs - const md = width.value < thresholds.lg && !(sm || xs) - const lg = width.value < thresholds.xl && !(md || sm || xs) - const xl = width.value < thresholds.xxl && !(lg || md || sm || xs) - const xxl = width.value >= thresholds.xxl - const name = - xs ? 'xs' - : sm ? 'sm' - : md ? 'md' - : lg ? 'lg' - : xl ? 'xl' - : 'xxl' - const breakpointValue = isNumber(mobileBreakpoint) ? mobileBreakpoint : thresholds[mobileBreakpoint] - const mobile = width.value < breakpointValue - - state.xs = xs - state.sm = sm - state.md = md - state.lg = lg - state.xl = xl - state.xxl = xxl - state.smAndUp = !xs - state.mdAndUp = !(xs || sm) - state.lgAndUp = !(xs || sm || md) - state.xlAndUp = !(xs || sm || md || lg) - state.smAndDown = !(md || lg || xl || xxl) - state.mdAndDown = !(lg || xl || xxl) - state.lgAndDown = !(xl || xxl) - state.xlAndDown = !xxl - state.name = name - state.height = height.value - state.width = width.value - state.mobile = mobile - state.mobileBreakpoint = mobileBreakpoint - state.platform = platform.value - state.thresholds = thresholds - }) - + // Resize stays here. createBreakpointsPlugin subscribes by replacing + // app.mount, and createVuetify already owns that hook for the SSR flush. if (IN_BROWSER) { - window.addEventListener('resize', updateSize, { passive: true }) + window.addEventListener('resize', screen.update, { passive: true }) onScopeDispose(() => { - window.removeEventListener('resize', updateSize) + window.removeEventListener('resize', screen.update) }, true) } - return { ...toRefs(state), update, ssr: !!ssr } + return { + xs: screen.xs, + sm: screen.sm, + md: screen.md, + lg: screen.lg, + xl: screen.xl, + xxl: screen.xxl, + smAndUp: screen.smAndUp, + mdAndUp: screen.mdAndUp, + lgAndUp: screen.lgAndUp, + xlAndUp: screen.xlAndUp, + smAndDown: screen.smAndDown, + mdAndDown: screen.mdAndDown, + lgAndDown: screen.lgAndDown, + xlAndDown: screen.xlAndDown, + name: screen.name, + height: screen.height, + width: screen.width, + mobile: screen.isMobile, + mobileBreakpoint: shallowRef(mobileBreakpoint), + platform, + thresholds: shallowRef(thresholds), + ssr: !!ssr, + update, + } as DisplayInstance } export const makeDisplayProps = propsFactory({ diff --git a/packages/vuetify/src/util/v0.ts b/packages/vuetify/src/util/v0.ts index 37da90bea03..7086a9b7380 100644 --- a/packages/vuetify/src/util/v0.ts +++ b/packages/vuetify/src/util/v0.ts @@ -21,7 +21,7 @@ export { export type { IgnoreAccents } from '@vuetify/v0/utilities' -export { createLocale as createV0Locale, createRtl as createV0Rtl } from '@vuetify/v0/composables' +export { createBreakpoints, createLocale as createV0Locale, createRtl as createV0Rtl } from '@vuetify/v0/composables' // Kept under core's existing name — `range` shadows local variables in several // components (VPagination, VRating, VSlider). From cf83a8b906499f72659d26df9fc6155ef02b3c9f Mon Sep 17 00:00:00 2001 From: John Leider Date: Thu, 24 Sep 2026 14:16:50 -0500 Subject: [PATCH 2/5] refactor(display): drop the shell resize listener @vuetify/v0 1.2.3 listens inside createBreakpoints. A second listener would run update twice per resize. The dependency floor is 1.2.3. --- packages/vuetify/package.json | 2 +- packages/vuetify/src/composables/display.ts | 12 +----------- pnpm-lock.yaml | 10 +++++----- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/vuetify/package.json b/packages/vuetify/package.json index 0ee3a601a8f..a3bb2985041 100755 --- a/packages/vuetify/package.json +++ b/packages/vuetify/package.json @@ -147,7 +147,7 @@ "lint:fix": "concurrently -n \"tsc,eslint\" \"tsgo -p tsconfig.checks.json --noEmit --pretty\" \"eslint --fix src\"" }, "dependencies": { - "@vuetify/v0": "^1.2.1" + "@vuetify/v0": "^1.2.3" }, "devDependencies": { "@date-io/core": "catalog:", diff --git a/packages/vuetify/src/composables/display.ts b/packages/vuetify/src/composables/display.ts index 4f40af3e06f..5814f8a1144 100644 --- a/packages/vuetify/src/composables/display.ts +++ b/packages/vuetify/src/composables/display.ts @@ -1,5 +1,5 @@ // Utilities -import { computed, inject, onScopeDispose, shallowRef, toRef } from 'vue' +import { computed, inject, shallowRef, toRef } from 'vue' import { createBreakpoints, getCurrentInstanceName, isNull, isNumber, isObject, mergeDeep, propsFactory } from '@/util' import { IN_BROWSER, SUPPORTS_TOUCH } from '@/util/globals' @@ -160,16 +160,6 @@ export function createDisplay (options?: DisplayOptions, ssr?: SSROptions): Disp platform.value = getPlatform() } - // Resize stays here. createBreakpointsPlugin subscribes by replacing - // app.mount, and createVuetify already owns that hook for the SSR flush. - if (IN_BROWSER) { - window.addEventListener('resize', screen.update, { passive: true }) - - onScopeDispose(() => { - window.removeEventListener('resize', screen.update) - }, true) - } - return { xs: screen.xs, sm: screen.sm, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4c674d8cf4..03c1b223ffd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1000,8 +1000,8 @@ importers: packages/vuetify: dependencies: '@vuetify/v0': - specifier: ^1.2.1 - version: 1.2.1(vue-i18n@11.2.1(vue@3.5.25(typescript@6.0.2)))(vue@3.5.25(typescript@6.0.2)) + specifier: ^1.2.3 + version: 1.2.3(vue-i18n@11.2.1(vue@3.5.25(typescript@6.0.2)))(vue@3.5.25(typescript@6.0.2)) devDependencies: '@date-io/core': specifier: 'catalog:' @@ -4264,8 +4264,8 @@ packages: resolution: {integrity: sha512-EHJDH+O7eov7yJ3WPR52qELyHq8beQ/GmletheroKRvp4SxTL9IgOEeXqBfw4bOyd49VNH5coyoqvIIlnhavUQ==} engines: {node: '>=18'} - '@vuetify/v0@1.2.1': - resolution: {integrity: sha512-lFBUeMmQiKbFR7JXC1Z74Z2dvvGLCqt/Wd9JQqg4falICTvHtlkulYqaHbtmByGV9eR7cbG1ohN1+7OQLLSPfg==} + '@vuetify/v0@1.2.3': + resolution: {integrity: sha512-8qpYa88KxKrvi4jQcz+Zg4WH04un48Lt7Tj0Qq+LqvbUvHi+H177vWqBn9tQWgpxBVs1RXrTz5zw0Cy6txJHlw==} peerDependencies: '@adobe/leonardo-contrast-colors': '>=1.0.0' '@ant-design/colors': '>=7.0.0' @@ -12704,7 +12704,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@vuetify/v0@1.2.1(vue-i18n@11.2.1(vue@3.5.25(typescript@6.0.2)))(vue@3.5.25(typescript@6.0.2))': + '@vuetify/v0@1.2.3(vue-i18n@11.2.1(vue@3.5.25(typescript@6.0.2)))(vue@3.5.25(typescript@6.0.2))': dependencies: vue: 3.5.25(typescript@6.0.2) optionalDependencies: From a828f9d726ebbbf7b4facaa52a601b680e9c3931 Mon Sep 17 00:00:00 2001 From: John Leider Date: Thu, 24 Sep 2026 15:41:35 -0500 Subject: [PATCH 3/5] fix(display): stop the ssr resize listener on unmount createBreakpoints attaches that listener on the first update(), which runs after createVuetify's scope has returned. A child scope created with the display is stopped with it. --- .../__tests__/display.spec.browser.ts | 27 ++++++++++++++++++- packages/vuetify/src/composables/display.ts | 10 +++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/vuetify/src/composables/__tests__/display.spec.browser.ts b/packages/vuetify/src/composables/__tests__/display.spec.browser.ts index 74dfe164486..728c5a461f8 100644 --- a/packages/vuetify/src/composables/__tests__/display.spec.browser.ts +++ b/packages/vuetify/src/composables/__tests__/display.spec.browser.ts @@ -3,7 +3,7 @@ import { createDisplay } from '../display' // Utilities import { page } from '@test' -import { effectScope } from 'vue' +import { effectScope, nextTick } from 'vue' const breakpoints = [ 'xs', @@ -287,6 +287,31 @@ describe('display', () => { await expect.poll(() => name.value).toBe('xs') }) + it('should remove the ssr resize listener when the scope stops', async () => { + const add = vi.spyOn(window, 'addEventListener') + const remove = vi.spyOn(window, 'removeEventListener') + const scope = effectScope() + + try { + const update = scope.run(() => { + return createDisplay(undefined, { clientWidth: 1024, clientHeight: 768 }).update + })! + + update() + await nextTick() + + expect(add).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true }) + + scope.stop() + + expect(remove).toHaveBeenCalledWith('resize', expect.any(Function), { passive: true }) + } finally { + scope.stop() + add.mockRestore() + remove.mockRestore() + } + }) + it('should allow breakpoint strings for mobileBreakpoint', async () => { const scope = effectScope() const { mobile } = scope.run(() => createDisplay({ mobileBreakpoint: 'lg' }))! diff --git a/packages/vuetify/src/composables/display.ts b/packages/vuetify/src/composables/display.ts index 5814f8a1144..e9ad482c1d3 100644 --- a/packages/vuetify/src/composables/display.ts +++ b/packages/vuetify/src/composables/display.ts @@ -1,5 +1,5 @@ // Utilities -import { computed, inject, shallowRef, toRef } from 'vue' +import { computed, effectScope, inject, shallowRef, toRef } from 'vue' import { createBreakpoints, getCurrentInstanceName, isNull, isNumber, isObject, mergeDeep, propsFactory } from '@/util' import { IN_BROWSER, SUPPORTS_TOUCH } from '@/util/globals' @@ -155,8 +155,14 @@ export function createDisplay (options?: DisplayOptions, ssr?: SSROptions): Disp const platform = shallowRef(getPlatform(ssr)) + // ssr defers the listener until update(), and that call happens after + // createVuetify's scope has returned. The child scope is still stopped + // with it, so unmount removes the listener. + const resizeScope = ssrSize ? effectScope() : undefined + function update () { - screen.update() + if (resizeScope) resizeScope.run(() => screen.update()) + else screen.update() platform.value = getPlatform() } From c88b7accd84748ac16d93a3e046a4a159a3309bf Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 25 Sep 2026 11:04:17 -0500 Subject: [PATCH 4/5] fix(display): compare component mobileBreakpoint with matchMedia The global mobile flag follows the CSS query. The component override was still comparing innerWidth, so the same lg threshold could disagree. The computed reads width, so it refreshes from the existing resize listener and does not subscribe to matchMedia itself. --- .../__tests__/display.spec.browser.ts | 47 +++++++++++++++++-- packages/vuetify/src/composables/display.ts | 18 +++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/vuetify/src/composables/__tests__/display.spec.browser.ts b/packages/vuetify/src/composables/__tests__/display.spec.browser.ts index 728c5a461f8..9855e4c6dcb 100644 --- a/packages/vuetify/src/composables/__tests__/display.spec.browser.ts +++ b/packages/vuetify/src/composables/__tests__/display.spec.browser.ts @@ -1,9 +1,9 @@ // Composables -import { createDisplay } from '../display' +import { createDisplay, DisplaySymbol, useDisplay } from '../display' // Utilities -import { page } from '@test' -import { effectScope, nextTick } from 'vue' +import { page, render } from '@test' +import { defineComponent, effectScope, h, inject, nextTick } from 'vue' const breakpoints = [ 'xs', @@ -312,6 +312,47 @@ describe('display', () => { } }) + it('should use the same media query for a component mobileBreakpoint as the global mobile flag', async () => { + await page.viewport(1600, 900) + + const original = window.matchMedia.bind(window) + const spy = vi.spyOn(window, 'matchMedia').mockImplementation(query => { + const list = original(query) + + if (query !== '(min-width: 1145px)') return list + + return new Proxy(list, { + get (target, prop, receiver) { + if (prop === 'matches') return false + + const value = Reflect.get(target, prop, receiver) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + }) + + try { + let local = false + let global = false + + render(defineComponent({ + setup () { + const display = inject(DisplaySymbol)! + const { mobile } = useDisplay({ mobile: null, mobileBreakpoint: 'lg' }) + local = mobile.value + global = display.mobile.value + + return () => h('div') + }, + })) + + expect(global).toBe(true) + expect(local).toBe(true) + } finally { + spy.mockRestore() + } + }) + it('should allow breakpoint strings for mobileBreakpoint', async () => { const scope = effectScope() const { mobile } = scope.run(() => createDisplay({ mobileBreakpoint: 'lg' }))! diff --git a/packages/vuetify/src/composables/display.ts b/packages/vuetify/src/composables/display.ts index e9ad482c1d3..c507eefa388 100644 --- a/packages/vuetify/src/composables/display.ts +++ b/packages/vuetify/src/composables/display.ts @@ -1,7 +1,7 @@ // Utilities import { computed, effectScope, inject, shallowRef, toRef } from 'vue' import { createBreakpoints, getCurrentInstanceName, isNull, isNumber, isObject, mergeDeep, propsFactory } from '@/util' -import { IN_BROWSER, SUPPORTS_TOUCH } from '@/util/globals' +import { IN_BROWSER, SUPPORTS_MATCH_MEDIA, SUPPORTS_TOUCH } from '@/util/globals' // Types import type { InjectionKey, PropType, Ref } from 'vue' @@ -209,13 +209,25 @@ export function useDisplay ( if (!display) throw new Error('Could not find Vuetify display injection') + // Same query createBreakpoints uses. Reading width subscribes this computed + // to the existing resize listener, so the matchMedia() call does not. + function isBelow (px: number, width: number) { + if (SUPPORTS_MATCH_MEDIA) { + return !window.matchMedia(`(min-width: ${px}px)`).matches + } + + return width < px + } + const mobile = computed(() => { + const width = display.width.value + if (props.mobile) { return true } else if (isNumber(props.mobileBreakpoint)) { - return display.width.value < props.mobileBreakpoint + return isBelow(props.mobileBreakpoint, width) } else if (props.mobileBreakpoint) { - return display.width.value < display.thresholds.value[props.mobileBreakpoint] + return isBelow(display.thresholds.value[props.mobileBreakpoint], width) } else if (isNull(props.mobile)) { return display.mobile.value } else { From 28305501fdc6e0c107a3f2792bf98932ba3ddada Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 25 Sep 2026 15:32:18 -0500 Subject: [PATCH 5/5] refactor(display): mark the width read as a reactivity trigger matchMedia() does not subscribe. Removing this read would freeze the component mobile flag on resize. --- packages/vuetify/src/composables/display.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/vuetify/src/composables/display.ts b/packages/vuetify/src/composables/display.ts index c507eefa388..421c83e7a53 100644 --- a/packages/vuetify/src/composables/display.ts +++ b/packages/vuetify/src/composables/display.ts @@ -209,8 +209,6 @@ export function useDisplay ( if (!display) throw new Error('Could not find Vuetify display injection') - // Same query createBreakpoints uses. Reading width subscribes this computed - // to the existing resize listener, so the matchMedia() call does not. function isBelow (px: number, width: number) { if (SUPPORTS_MATCH_MEDIA) { return !window.matchMedia(`(min-width: ${px}px)`).matches @@ -220,6 +218,7 @@ export function useDisplay ( } const mobile = computed(() => { + // Reactivity trigger, do not remove. matchMedia() does not subscribe. const width = display.width.value if (props.mobile) {