fix(plugin-mobile): restore mobile page scroll (#9898)

* fix(plugin-mobile): restore mobile page scroll

* fix(plugin-mobile): scope mutation observer

* fix(plugin-public-forms): restore mobile scroll
This commit is contained in:
Zeke Zhang
2026-07-01 09:20:26 +08:00
committed by GitHub
parent 97218e4581
commit fd52b393f0
4 changed files with 683 additions and 16 deletions
@@ -12,8 +12,79 @@ import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/t
import Basic from '../../demos/pages-page-content-basic';
import FirstRoute from '../../demos/pages-page-content-first-route';
import NotFound from '../../demos/pages-page-content-404';
import { MobilePageContentContainer } from '../../pages/dynamic-page/content';
const device = vi.hoisted(() => ({ isDesktop: false }));
vi.mock('react-device-detect', async () => ({
...(await vi.importActual('react-device-detect')),
get isDesktop() {
return device.isDesktop;
},
}));
describe('MobilePageContent', () => {
let resizeObserverCallback: ResizeObserverCallback | undefined;
let resizeObserverObserve: ReturnType<typeof vi.fn>;
let mutationObserverObserve: ReturnType<typeof vi.fn>;
const mockDynamicViewportUnitSupport = (supported: boolean) => {
vi.stubGlobal('CSS', {
supports: vi.fn(() => supported),
});
};
const notifyResizeObserver = async () => {
const resizeObserver = {
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
} as unknown as ResizeObserver;
await act(async () => {
resizeObserverCallback?.([], resizeObserver);
});
};
const flushLayoutFrame = async () => {
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
});
};
beforeEach(() => {
device.isDesktop = false;
resizeObserverCallback = undefined;
resizeObserverObserve = vi.fn();
mutationObserverObserve = vi.fn();
vi.stubGlobal(
'ResizeObserver',
vi.fn((callback: ResizeObserverCallback) => {
resizeObserverCallback = callback;
return {
observe: resizeObserverObserve,
disconnect: vi.fn(),
};
}),
);
vi.stubGlobal(
'MutationObserver',
vi.fn(() => {
return {
observe: mutationObserverObserve,
disconnect: vi.fn(),
};
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('basic', async () => {
render(<Basic />);
await waitForApp();
@@ -48,4 +119,255 @@ describe('MobilePageContent', () => {
expect(screen.queryByText('404')).not.toBeInTheDocument();
});
});
it('keeps page content scrollable on mobile devices', async () => {
mockDynamicViewportUnitSupport(true);
let headerHeight = 50;
let tabBarHeight = 49;
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
if (this.classList.contains('mobile-page-header')) {
return headerHeight;
}
if (this.classList.contains('mobile-tab-bar')) {
return tabBarHeight;
}
return 0;
});
render(
<>
<div className="mobile-page-header" />
<MobilePageContentContainer>
<div>Scrollable content</div>
</MobilePageContentContainer>
<div className="mobile-tab-bar" />
</>,
);
await waitFor(() => {
const content = screen.getByTestId('mobile-page-content');
expect(content.style.height).toBe('calc(100dvh - 99px)');
expect(content.style.overflowY).toBe('auto');
expect(content.style.WebkitOverflowScrolling).toBe('touch');
});
headerHeight = 80;
tabBarHeight = 60;
await notifyResizeObserver();
await waitFor(() => {
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 140px)');
});
});
it('does not re-observe layout elements from resize notifications', async () => {
mockDynamicViewportUnitSupport(true);
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
if (this.classList.contains('mobile-page-header')) {
return 50;
}
if (this.classList.contains('mobile-tab-bar')) {
return 49;
}
return 0;
});
render(
<>
<div className="mobile-page-header" />
<MobilePageContentContainer>
<div>Scrollable content</div>
</MobilePageContentContainer>
<div className="mobile-tab-bar" />
</>,
);
await waitFor(() => {
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 99px)');
});
await flushLayoutFrame();
const observeCount = resizeObserverObserve.mock.calls.length;
await notifyResizeObserver();
await flushLayoutFrame();
expect(resizeObserverObserve).toHaveBeenCalledTimes(observeCount);
});
it('scopes mutation observation to the mobile container when available', async () => {
mockDynamicViewportUnitSupport(true);
render(
<div className="mobile-container" data-testid="mobile-container">
<MobilePageContentContainer>
<div>Scrollable content</div>
</MobilePageContentContainer>
</div>,
);
await waitFor(() => {
expect(mutationObserverObserve).toHaveBeenCalledWith(screen.getByTestId('mobile-container'), {
childList: true,
subtree: true,
});
});
});
it('falls back to 100vh when dynamic viewport units are unsupported', async () => {
mockDynamicViewportUnitSupport(false);
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
if (this.classList.contains('mobile-page-header')) {
return 50;
}
if (this.classList.contains('mobile-tab-bar')) {
return 49;
}
return 0;
});
render(
<>
<div className="mobile-page-header" />
<MobilePageContentContainer>
<div>Scrollable content</div>
</MobilePageContentContainer>
<div className="mobile-tab-bar" />
</>,
);
await waitFor(() => {
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100vh - 99px)');
});
});
it('does not reserve a hidden page header height', async () => {
mockDynamicViewportUnitSupport(true);
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
if (this.classList.contains('mobile-page-header')) {
return 50;
}
if (this.classList.contains('mobile-tab-bar')) {
return 49;
}
return 0;
});
const { rerender } = render(
<>
<div className="mobile-page-header" />
<MobilePageContentContainer>
<div>Scrollable content</div>
</MobilePageContentContainer>
<div className="mobile-tab-bar" />
</>,
);
await waitFor(() => {
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 99px)');
});
rerender(
<>
<div className="mobile-page-header" />
<MobilePageContentContainer displayPageHeader={false}>
<div>Scrollable content</div>
</MobilePageContentContainer>
<div className="mobile-tab-bar" />
</>,
);
await waitFor(() => {
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 49px)');
});
});
it('reserves fixed action page footer space in the scroll container', async () => {
mockDynamicViewportUnitSupport(true);
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
if (this.classList.contains('nb-mobile-action-page-footer')) {
return 46;
}
return 0;
});
render(
<div className="nb-mobile-action-page">
<MobilePageContentContainer hideTabBar displayPageHeader={false}>
<div>Scrollable content</div>
</MobilePageContentContainer>
<div className="nb-mobile-action-page-footer" />
</div>,
);
await waitFor(() => {
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 46px)');
});
});
it('does not reserve a parent action page footer for a nested action page without footer', async () => {
mockDynamicViewportUnitSupport(true);
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
if (this.classList.contains('nb-mobile-action-page-footer')) {
return 46;
}
return 0;
});
render(
<div className="nb-mobile-action-page">
<div className="nb-mobile-action-page">
<MobilePageContentContainer hideTabBar displayPageHeader={false}>
<div>Nested content</div>
</MobilePageContentContainer>
</div>
<div className="nb-mobile-action-page-footer" />
</div>,
);
await waitFor(() => {
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 0px)');
});
});
it('keeps desktop preview height based on its container', async () => {
device.isDesktop = true;
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
if (this.classList.contains('mobile-page-header')) {
return 50;
}
if (this.classList.contains('mobile-tab-bar')) {
return 49;
}
return 0;
});
render(
<>
<div className="mobile-page-header" />
<MobilePageContentContainer>
<div>Scrollable content</div>
</MobilePageContentContainer>
<div className="mobile-tab-bar" />
</>,
);
await waitFor(() => {
const content = screen.getByTestId('mobile-page-content');
expect(content.style.height).toBe('calc(100% - 99px)');
expect(content.style.overflowY).toBe('');
expect(content.style.WebkitOverflowScrolling).toBe('');
});
});
});
@@ -8,11 +8,25 @@
*/
import { useToken } from '@nocobase/client';
import { useMemoizedFn } from 'ahooks';
import _ from 'lodash';
import React, { FC, useEffect } from 'react';
import classnames from 'classnames';
import { isDesktop } from 'react-device-detect';
import { PageBackgroundColor } from '../../../constants';
const getContentHeightBase = () => {
if (isDesktop) {
return '100%';
}
if (typeof CSS !== 'undefined' && CSS.supports?.('height', '100dvh')) {
return '100dvh';
}
return '100vh';
};
export const MobilePageContentContainer: FC<{
hideTabBar?: boolean;
displayPageHeader?: boolean;
@@ -20,29 +34,116 @@ export const MobilePageContentContainer: FC<{
}> = ({ children, hideTabBar, displayPageHeader = true, className }) => {
const [mobileTabBarHeight, setMobileTabBarHeight] = React.useState(0);
const [mobilePageHeader, setMobilePageHeader] = React.useState(0);
const [mobileActionPageFooterHeight, setMobileActionPageFooterHeight] = React.useState(0);
const contentRef = React.useRef<HTMLDivElement>(null);
const { token } = useToken();
const contentHeightBase = getContentHeightBase();
const getMobileActionPageFooter = useMemoizedFn(() => {
const actionPage = contentRef.current?.closest('.nb-mobile-action-page');
if (!actionPage) {
return null;
}
return (
Array.from(actionPage.children).find(
(element): element is HTMLDivElement =>
element instanceof HTMLDivElement && element.classList.contains('nb-mobile-action-page-footer'),
) || null
);
});
const getLayoutElements = useMemoizedFn(() => {
const navigationBar = displayPageHeader
? _.last(document.querySelectorAll<HTMLDivElement>('.mobile-page-header'))
: null;
const mobileTabBar = hideTabBar ? null : document.querySelector<HTMLDivElement>('.mobile-tab-bar');
const mobileActionPageFooter = getMobileActionPageFooter();
return { mobileActionPageFooter, mobileTabBar, navigationBar };
});
const updateLayoutHeights = useMemoizedFn(() => {
const { mobileActionPageFooter, mobileTabBar, navigationBar } = getLayoutElements();
setMobilePageHeader(navigationBar?.offsetHeight || 0);
setMobileTabBarHeight(mobileTabBar?.offsetHeight || 0);
setMobileActionPageFooterHeight(mobileActionPageFooter?.offsetHeight || 0);
});
const occupiedHeight = (mobileTabBarHeight || 0) + (mobilePageHeader || 0) + (mobileActionPageFooterHeight || 0);
const bottomSpacerHeight = (mobileTabBarHeight || 0) + (mobileActionPageFooterHeight || 0);
useEffect(() => {
const navigationBar = _.last(document.querySelectorAll<HTMLDivElement>('.mobile-page-header'));
setMobilePageHeader(navigationBar?.offsetHeight);
let resizeObserver: ResizeObserver | null = null;
let animationFrame: number | null = null;
let shouldObserveOnNextFrame = false;
const scheduleLayoutUpdate = (shouldObserveLayoutElements = false) => {
shouldObserveOnNextFrame = shouldObserveOnNextFrame || shouldObserveLayoutElements;
if (animationFrame !== null) {
return;
}
if (!hideTabBar) {
const mobileTabBar = document.querySelector<HTMLDivElement>('.mobile-tab-bar');
setMobileTabBarHeight(mobileTabBar?.offsetHeight);
animationFrame = requestAnimationFrame(() => {
const shouldObserve = shouldObserveOnNextFrame;
shouldObserveOnNextFrame = false;
animationFrame = null;
updateLayoutHeights();
if (shouldObserve) {
observeLayoutElements();
}
});
};
const observeLayoutElements = () => {
resizeObserver?.disconnect();
Object.values(getLayoutElements())
.filter(Boolean)
.forEach((element) => {
resizeObserver?.observe(element);
});
};
const mutationObserver =
typeof MutationObserver === 'undefined'
? null
: new MutationObserver(() => {
scheduleLayoutUpdate(true);
});
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
scheduleLayoutUpdate();
});
}
// 这里依赖项要不需要填,每次都刷新
});
updateLayoutHeights();
observeLayoutElements();
scheduleLayoutUpdate();
const mutationObserverTarget = document.querySelector<HTMLElement>('.mobile-container') || document.body;
mutationObserver?.observe(mutationObserverTarget, { childList: true, subtree: true });
const handleWindowResize = () => {
scheduleLayoutUpdate(true);
};
window.addEventListener('resize', handleWindowResize);
return () => {
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame);
}
mutationObserver?.disconnect();
resizeObserver?.disconnect();
window.removeEventListener('resize', handleWindowResize);
};
}, [displayPageHeader, getLayoutElements, hideTabBar, updateLayoutHeights]);
return (
<>
{mobilePageHeader && displayPageHeader ? <div style={{ height: mobilePageHeader }}></div> : null}
<div
ref={contentRef}
className={classnames('mobile-page-content', className)}
data-testid="mobile-page-content"
style={{
height: `calc(100% - ${(mobileTabBarHeight || 0) + (mobilePageHeader || 0)}px)`,
height: `calc(${contentHeightBase} - ${occupiedHeight}px)`,
boxSizing: 'border-box',
maxWidth: '100%',
overflowX: 'hidden',
overflowY: isDesktop ? undefined : 'auto',
WebkitOverflowScrolling: isDesktop ? undefined : 'touch',
backgroundColor: PageBackgroundColor,
paddingInline: token.paddingPageHorizontal,
paddingBlock: token.paddingPageVertical,
@@ -50,7 +151,7 @@ export const MobilePageContentContainer: FC<{
>
{children}
</div>
{mobileTabBarHeight ? <div style={{ height: mobileTabBarHeight }}></div> : null}
{bottomSpacerHeight ? <div style={{ height: bottomSpacerHeight }}></div> : null}
</>
);
};
@@ -0,0 +1,224 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { render, screen } from '@nocobase/test/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PublicFormPage } from '../components/PublicFormPage';
const mocks = vi.hoisted(() => {
const requestInterceptorUse = vi.fn(() => 1);
const requestInterceptorEject = vi.fn();
return {
device: {
isDesktop: false,
isMobile: true,
},
requestInterceptorEject,
requestInterceptorUse,
useRequest: vi.fn(() => ({
data: {
data: {
dataSource: { key: 'main' },
schema: {},
title: 'Public form',
token: 'form-token',
},
},
error: null,
loading: false,
run: vi.fn(),
})),
};
});
vi.mock('react-device-detect', async () => ({
...(await vi.importActual('react-device-detect')),
get isDesktop() {
return mocks.device.isDesktop;
},
get isMobile() {
return mocks.device.isMobile;
},
}));
vi.mock('react-router', () => ({
useParams: () => ({ name: 'public-form-1' }),
}));
vi.mock('@formily/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@formily/react')>();
return {
...actual,
useField: () => ({
form: {
query: () => ({
take: vi.fn(),
}),
},
}),
};
});
vi.mock('../components/components/MobileDatePicker', () => ({
MobileDateTimePicker: () => <div data-testid="mobile-date-time-picker" />,
}));
vi.mock('../components/components/MobilePicker', () => ({
MobilePicker: () => <div data-testid="mobile-picker" />,
}));
vi.mock('../hooks', () => ({
usePublicSubmitActionProps: vi.fn(),
}));
vi.mock('@nocobase/client', () => {
const SchemaComponentContext = React.createContext({});
const AssociationField = Object.assign(() => <div data-testid="association-field" />, {
AddNewer: () => null,
FileSelector: () => null,
InternalSelect: () => null,
Nester: () => null,
ReadPretty: () => null,
Selector: () => null,
SubTable: () => null,
Viewer: () => null,
});
const DatePicker = Object.assign(() => <div data-testid="date-picker" />, {
FilterWithPicker: () => null,
RangePicker: () => null,
});
return {
ACLCustomContext: React.createContext({}),
Action: { Container: ({ children }: React.PropsWithChildren) => <>{children}</> },
APIClientProvider: ({ children }: React.PropsWithChildren<{ apiClient?: unknown }>) => <>{children}</>,
AssociationField,
CollectionManager: class CollectionManager {},
DataSource: class DataSource {},
DataSourceApplicationProvider: ({ children }: React.PropsWithChildren) => <>{children}</>,
DataSourceManager: class DataSourceManager {
addDataSource() {
return {};
}
},
DatePicker,
GlobalThemeProvider: ({ children }: React.PropsWithChildren<{ theme?: unknown }>) => <>{children}</>,
PoweredBy: () => <div>Powered by NocoBase</div>,
SchemaComponent: () => (
<div data-testid="public-form-schema" style={{ height: 2000 }}>
Public form content
</div>
),
SchemaComponentContext,
VariablesProvider: ({ children }: React.PropsWithChildren<{ filterVariables?: unknown }>) => <>{children}</>,
useApp: () => ({
apiClient: {
axios: {
interceptors: {
request: {
eject: mocks.requestInterceptorEject,
use: mocks.requestInterceptorUse,
},
},
},
},
}),
useCompile: () => (value: string) => value,
useRequest: mocks.useRequest,
};
});
vi.mock('antd', async (importOriginal) => {
const actual = await importOriginal<typeof import('antd')>();
return {
...actual,
Input: {
...actual.Input,
Password: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} type="password" />,
},
Modal: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
Spin: () => <div>Loading</div>,
};
});
vi.mock('antd-mobile', () => ({
Button: ({ children }: React.PropsWithChildren) => <button>{children}</button>,
Dialog: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
}));
describe('PublicFormPage', () => {
const findPublicFormContainer = () =>
Array.from(document.querySelectorAll('div')).find(
(element) =>
(element.style.overflowY === 'auto' || element.style.overflow === 'auto') &&
element.textContent?.includes('Public form content'),
) as HTMLDivElement | undefined;
beforeEach(() => {
mocks.device.isDesktop = false;
mocks.device.isMobile = true;
vi.stubGlobal('CSS', {
supports: vi.fn(() => true),
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
document.body.style.backgroundColor = '';
document.body.style.overflow = '';
});
it('uses the viewport height as the mobile public form scroll container height', async () => {
render(<PublicFormPage />);
expect(await screen.findByTestId('public-form-schema')).toBeInTheDocument();
const publicFormContainer = findPublicFormContainer();
expect(publicFormContainer).toBeDefined();
expect(publicFormContainer?.style.minHeight).toBe('100dvh');
expect(publicFormContainer?.style.overflowY).toBe('auto');
expect(publicFormContainer?.style.WebkitOverflowScrolling).toBe('touch');
});
it('falls back to the viewport height supported by jsdom when dvh is unavailable', async () => {
vi.stubGlobal('CSS', {
supports: vi.fn(() => false),
});
render(<PublicFormPage />);
expect(await screen.findByTestId('public-form-schema')).toBeInTheDocument();
const publicFormContainer = findPublicFormContainer();
expect(publicFormContainer).toBeDefined();
expect(publicFormContainer?.style.minHeight).toBe('100vh');
expect(publicFormContainer?.style.height).toBe('100vh');
expect(publicFormContainer?.style.overflowY).toBe('auto');
});
it('keeps the desktop public form overflow behavior unchanged', async () => {
mocks.device.isDesktop = true;
mocks.device.isMobile = false;
render(<PublicFormPage />);
expect(await screen.findByTestId('public-form-schema')).toBeInTheDocument();
const publicFormContainer = findPublicFormContainer();
expect(publicFormContainer).toBeDefined();
expect(publicFormContainer?.style.height).toBe('100%');
expect(publicFormContainer?.style.overflow).toBe('auto');
expect(publicFormContainer?.style.overflowX).toBe('');
});
});
@@ -9,6 +9,7 @@
import { css } from '@emotion/css';
import { useField } from '@formily/react';
import { useMemoizedFn } from 'ahooks';
import {
ACLCustomContext,
Action,
@@ -90,24 +91,36 @@ function useTitle(data) {
useEffect(() => {
if (!data) return;
document.title = compile(data?.data?.title);
}, [data]);
}, [compile, data]);
}
export const PublicFormMessageContext = createContext<any>({});
export const PageBackgroundColor = '#f5f5f5';
const getPublicFormContainerHeight = () => {
if (isDesktop) {
return '100%';
}
if (typeof CSS !== 'undefined' && CSS.supports?.('height', '100dvh')) {
return '100dvh';
}
return '100vh';
};
const PublicFormMessageProvider = ({ children }) => {
const [showMessage, setShowMessage] = useState(false);
const field = useField();
const toggleFieldVisibility = (fieldName, visible) => {
const toggleFieldVisibility = useMemoizedFn((fieldName, visible) => {
field.form.query(fieldName).take((f) => {
if (f) {
f.visible = visible;
f.hidden = !visible;
}
});
};
});
useEffect(() => {
toggleFieldVisibility('success', showMessage);
@@ -120,7 +133,7 @@ const PublicFormMessageProvider = ({ children }) => {
}
});
}
}, [showMessage]);
}, [field.form, showMessage, toggleFieldVisibility]);
return (
<PublicFormMessageContext.Provider value={{ showMessage, setShowMessage }}>
@@ -233,15 +246,22 @@ function InternalPublicForm() {
return <UnEnabledFormPlaceholder />;
}
const components = isMobile ? mobileComponents : {};
const containerHeight = getPublicFormContainerHeight();
return (
<ACLCustomContext.Provider value={{ allowAll: true }}>
<PublicAPIClientProvider>
<div
style={{
minHeight: '100vh',
minHeight: isDesktop ? '100vh' : containerHeight,
background: PageBackgroundColor,
height: '100%',
overflow: 'auto',
height: containerHeight,
...(isDesktop
? { overflow: 'auto' }
: {
overflowX: 'hidden',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
}),
}}
>
<div