Skip to content

refactor(@mantine/hooks): update useViewportSize to useSyncExternalStore#8732

Open
mocutasorin wants to merge 2 commits intomantinedev:masterfrom
mocutasorin:refactor/use-viewport-size-sync-external-store
Open

refactor(@mantine/hooks): update useViewportSize to useSyncExternalStore#8732
mocutasorin wants to merge 2 commits intomantinedev:masterfrom
mocutasorin:refactor/use-viewport-size-sync-external-store

Conversation

@mocutasorin
Copy link
Copy Markdown

This PR refactors the useViewportSize hook to utilize React 18's useSyncExternalStore instead of the legacy useState + useEffect pattern.

Motivation & Benefits:

  1. Prevents Double Renders: The previous implementation initialized with { width: 0, height: 0 } and then updated via useEffect after the component mounted. useSyncExternalStore reads the actual window dimensions synchronously during the initial client render, preventing layout shifts or visual flashes.
  2. Concurrent Rendering Safe: Fully compatible with React 18's concurrent features, preventing "tearing" if the window resizes during a paused render transition.
  3. Global Subscription: The subscription logic and cache have been hoisted outside the hook. If multiple components use useViewportSize simultaneously, they now share a single cache and window event listener, rather than attaching multiple redundant listeners via useWindowEvent.
  4. Hydration Safe: Safely falls back to { width: 0, height: 0 } on the server via getServerSnapshot to prevent hydration mismatches, matching the existing API behavior.

Changes:

  • Replaced useState and useEffect with useSyncExternalStore.
  • Hoisted event listeners outside the component tree.
  • Retained { passive: true } for scroll/resize performance.
  • Retained identical return types and API contract.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors @mantine/hooksuseViewportSize 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 useSyncExternalStore for viewport size reads.
  • Introduced module-level snapshot/cache logic and a custom subscribe implementation.
  • Switched from useWindowEvent usage to direct window.addEventListener/removeEventListener.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +9 to +18
let isInitialized = false;

function getSnapshot() {
if (typeof window === 'undefined') {
return serverFallback;
}

if (!isInitialized) {
cachedSize = { width: window.innerWidth, height: window.innerHeight };
isInitialized = true;
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 };

Copilot uses AI. Check for mistakes.
Comment on lines 28 to 45
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);

Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
import { useSyncExternalStore } from 'react';

const eventListerOptions = {
const eventListerOptions: AddEventListenerOptions = {
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in identifier name: eventListerOptions should be eventListenerOptions ("listener" is misspelled). Renaming improves readability and makes it easier to grep across the codebase.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +53
export function useViewportSize() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@mocutasorin
Copy link
Copy Markdown
Author

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants