refactor(@mantine/hooks): update useViewportSize to useSyncExternalStore#8732
refactor(@mantine/hooks): update useViewportSize to useSyncExternalStore#8732mocutasorin wants to merge 2 commits intomantinedev:masterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Refactors @mantine/hooks’ useViewportSize to use React 18’s useSyncExternalStore, aiming to improve concurrent rendering safety and avoid the legacy useState + useEffect initialization flash.
Changes:
- Replaced local state/effects with
useSyncExternalStorefor viewport size reads. - Introduced module-level snapshot/cache logic and a custom
subscribeimplementation. - Switched from
useWindowEventusage to directwindow.addEventListener/removeEventListener.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let isInitialized = false; | ||
|
|
||
| function getSnapshot() { | ||
| if (typeof window === 'undefined') { | ||
| return serverFallback; | ||
| } | ||
|
|
||
| if (!isInitialized) { | ||
| cachedSize = { width: window.innerWidth, height: window.innerHeight }; | ||
| isInitialized = true; |
There was a problem hiding this comment.
getSnapshot only reads window.innerWidth/innerHeight once (guarded by isInitialized). If all subscribers unmount and the viewport changes before the hook is used again, the next mount will return a stale cachedSize and may never update until another resize/orientation event occurs. Consider computing the snapshot from window on every call (updating cachedSize when it differs) or resetting/recomputing when there are no active subscribers.
| let isInitialized = false; | |
| function getSnapshot() { | |
| if (typeof window === 'undefined') { | |
| return serverFallback; | |
| } | |
| if (!isInitialized) { | |
| cachedSize = { width: window.innerWidth, height: window.innerHeight }; | |
| isInitialized = true; | |
| function getSnapshot() { | |
| if (typeof window === 'undefined') { | |
| return serverFallback; | |
| } | |
| const newWidth = window.innerWidth; | |
| const newHeight = window.innerHeight; | |
| if (cachedSize.width !== newWidth || cachedSize.height !== newHeight) { | |
| cachedSize = { width: newWidth, height: newHeight }; |
| function subscribe(callback: () => void) { | ||
| if (typeof window === 'undefined') { | ||
| return () => {}; | ||
| } | ||
|
|
||
| const handleResize = () => { | ||
| const newWidth = window.innerWidth; | ||
| const newHeight = window.innerHeight; | ||
|
|
||
| const setSize = useCallback(() => { | ||
| setWindowSize({ width: window.innerWidth || 0, height: window.innerHeight || 0 }); | ||
| }, []); | ||
| if (cachedSize.width !== newWidth || cachedSize.height !== newHeight) { | ||
| cachedSize = { width: newWidth, height: newHeight }; | ||
| callback(); | ||
| } | ||
| }; | ||
|
|
||
| useWindowEvent('resize', setSize, eventListerOptions); | ||
| useWindowEvent('orientationchange', setSize, eventListerOptions); | ||
| useEffect(setSize, []); | ||
| window.addEventListener('resize', handleResize, eventListerOptions); | ||
| window.addEventListener('orientationchange', handleResize, eventListerOptions); | ||
|
|
There was a problem hiding this comment.
subscribe currently adds its own resize/orientationchange listeners per hook instance. That means multiple components using useViewportSize will still register multiple window listeners, which contradicts the PR description’s “single cache and window event listener” benefit. If you want a shared subscription, consider keeping a module-level Set of subscriber callbacks plus a single global handleResize, and attach/detach the window listeners using reference counting when the first subscriber mounts / last unsubscribes.
| import { useSyncExternalStore } from 'react'; | ||
|
|
||
| const eventListerOptions = { | ||
| const eventListerOptions: AddEventListenerOptions = { |
There was a problem hiding this comment.
Typo in identifier name: eventListerOptions should be eventListenerOptions ("listener" is misspelled). Renaming improves readability and makes it easier to grep across the codebase.
| export function useViewportSize() { | ||
| return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
There was a problem hiding this comment.
This is a behavioral refactor (new global cache + useSyncExternalStore subscription semantics), but there is no test coverage for useViewportSize while many other hooks in this package have dedicated tests. Adding tests that validate initial snapshot (matches window.innerWidth/innerHeight), updates on resize/orientationchange, and correct behavior across unmount/remount would help prevent regressions.
|
@rtivital I was doing some deeper testing on this PR and dug into React 18's hydration rules, and I actually found a significant edge case that makes me rethink this approach for Mantine. Because the server snapshot ({ width: 0, height: 0 }) will inherently mismatch the client snapshot ({ width: 1920, height: 1080 }) on mount, useSyncExternalStore forces a synchronous re-render during hydration to prevent tearing. The issue is that if a Mantine user wraps a component using useViewportSize inside a boundary, this synchronous hydration mismatch will instantly trigger the Suspense fallback (flashing a loading spinner/state). The current useState + useEffect approach safely avoids this because the effect runs after the initial paint. Given this, the current useEffect implementation is actually much safer for a component library where we don't know how users are structuring their Suspense boundaries. If you agree with this assessment, let's close this PR. |
This PR refactors the
useViewportSizehook to utilize React 18'suseSyncExternalStoreinstead of the legacyuseState+useEffectpattern.Motivation & Benefits:
{ width: 0, height: 0 }and then updated viauseEffectafter the component mounted.useSyncExternalStorereads the actual window dimensions synchronously during the initial client render, preventing layout shifts or visual flashes.useViewportSizesimultaneously, they now share a single cache andwindowevent listener, rather than attaching multiple redundant listeners viauseWindowEvent.{ width: 0, height: 0 }on the server viagetServerSnapshotto prevent hydration mismatches, matching the existing API behavior.Changes:
useStateanduseEffectwithuseSyncExternalStore.{ passive: true }for scroll/resize performance.