fix(client): defer MobileAppModal rendering until after first browser paint (#67154)

This commit is contained in:
Huyen Nguyen
2026-04-28 23:22:47 +07:00
committed by GitHub
parent 2548e2bb2f
commit 90cc514f78
2 changed files with 30 additions and 1 deletions

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { renderToString } from 'react-dom/server';
import { render, screen, fireEvent } from '@testing-library/react';
import {
describe,
@@ -75,6 +76,14 @@ describe('MobileAppModal', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('does not render before mount', () => {
// useEffect does not run during server-side rendering, so the 'mounted'
// flag stays false and the component should produce no output.
expect(
renderToString(<MobileAppModal superBlock={MOBILE_SUPERBLOCK} />)
).toBe('');
});
it('does not render on a desktop OS', () => {
Object.defineProperty(navigator, 'userAgent', {
value: DESKTOP_UA,

View File

@@ -58,6 +58,20 @@ function MobileAppModal({
const os = detectOS();
const [dismissed, setDismissed] = useState(isDismissedFor30Days);
// Defer rendering until after the first browser paint. On a direct page
// load the component hydrates before the browser has computed layout, so
// document.documentElement.clientWidth is 0. The Modal's scroll-lock
// utility calculates the scrollbar compensation as
// window.innerWidth - clientWidth, which produces window.innerWidth when
// clientWidth is 0, and stamps that value as padding-right on <html>,
// breaking the layout. Waiting for useEffect guarantees that layout has
// been computed before the modal (and its scroll-lock) opens.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isProjectPreviewOpen) setDismissed(true);
}, [isProjectPreviewOpen]);
@@ -73,7 +87,13 @@ function MobileAppModal({
const storeName =
os === 'ios' ? t('mobile-app-modal.ios') : t('mobile-app-modal.android');
if (os === 'other' || !isAvailable || isProjectPreviewOpen || dismissed)
if (
!mounted ||
os === 'other' ||
!isAvailable ||
isProjectPreviewOpen ||
dismissed
)
return null;
return (