diff --git a/components/_util/__tests__/responsiveObserve.test.ts b/components/_util/__tests__/responsiveObserve.test.ts deleted file mode 100644 index 6ab86ec802ea..000000000000 --- a/components/_util/__tests__/responsiveObserve.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import ResponsiveObserve, { responsiveMap } from '../responsiveObserve'; - -describe('Test ResponsiveObserve', () => { - it('test ResponsiveObserve subscribe and unsubscribe', () => { - const { xs } = responsiveMap; - const subscribeFunc = jest.fn(); - const token = ResponsiveObserve.subscribe(subscribeFunc); - expect(ResponsiveObserve.matchHandlers[xs].mql.matches).toBeTruthy(); - expect(subscribeFunc).toHaveBeenCalledTimes(1); - - ResponsiveObserve.unsubscribe(token); - expect(ResponsiveObserve.matchHandlers[xs].mql.removeListener).toHaveBeenCalled(); - }); -}); diff --git a/components/_util/__tests__/responsiveObserve.test.tsx b/components/_util/__tests__/responsiveObserve.test.tsx new file mode 100644 index 000000000000..0bcb6167ca87 --- /dev/null +++ b/components/_util/__tests__/responsiveObserve.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render } from '../../../tests/utils'; +import useResponsiveObserve from '../responsiveObserve'; + +describe('Test ResponsiveObserve', () => { + it('test ResponsiveObserve subscribe and unsubscribe', () => { + let responsiveObserveRef: any; + const Demo = () => { + const responsiveObserve = useResponsiveObserve(); + responsiveObserveRef = responsiveObserve; + return null; + }; + render(); + const subscribeFunc = jest.fn(); + const token = responsiveObserveRef.subscribe(subscribeFunc); + expect( + responsiveObserveRef.matchHandlers[responsiveObserveRef.responsiveMap.xs].mql.matches, + ).toBeTruthy(); + expect(subscribeFunc).toHaveBeenCalledTimes(1); + + responsiveObserveRef.unsubscribe(token); + expect( + responsiveObserveRef.matchHandlers[responsiveObserveRef.responsiveMap.xs].mql.removeListener, + ).toHaveBeenCalled(); + }); +}); diff --git a/components/_util/responsiveObserve.ts b/components/_util/responsiveObserve.ts index 65d535699eab..d353b251a6d4 100644 --- a/components/_util/responsiveObserve.ts +++ b/components/_util/responsiveObserve.ts @@ -1,74 +1,85 @@ +import React from 'react'; +import type { GlobalToken } from '../theme/interface'; +import { useToken } from '../theme/internal'; + export type Breakpoint = 'xxl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs'; export type BreakpointMap = Record; export type ScreenMap = Partial>; export type ScreenSizeMap = Partial>; export const responsiveArray: Breakpoint[] = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs']; +type SubscribeFunc = (screens: ScreenMap) => void; -export const responsiveMap: BreakpointMap = { - xs: '(max-width: 575px)', - sm: '(min-width: 576px)', - md: '(min-width: 768px)', - lg: '(min-width: 992px)', - xl: '(min-width: 1200px)', - xxl: '(min-width: 1600px)', -}; +const getResponsiveMap = (token: GlobalToken): BreakpointMap => ({ + xs: `(max-width: ${token.screenXSMax}px)`, + sm: `(min-width: ${token.screenSM}px)`, + md: `(min-width: ${token.screenMD}px)`, + lg: `(min-width: ${token.screenLG}px)`, + xl: `(min-width: ${token.screenXL}px)`, + xxl: `(min-width: ${token.screenXXL}px)`, +}); -type SubscribeFunc = (screens: ScreenMap) => void; -const subscribers = new Map(); -let subUid = -1; -let screens = {}; +export default function useResponsiveObserver() { + const [, token] = useToken(); + const responsiveMap: BreakpointMap = getResponsiveMap(token); -const responsiveObserve = { - matchHandlers: {} as { - [prop: string]: { - mql: MediaQueryList; - listener: ((this: MediaQueryList, ev: MediaQueryListEvent) => any) | null; - }; - }, - dispatch(pointMap: ScreenMap) { - screens = pointMap; - subscribers.forEach(func => func(screens)); - return subscribers.size >= 1; - }, - subscribe(func: SubscribeFunc): number { - if (!subscribers.size) this.register(); - subUid += 1; - subscribers.set(subUid, func); - func(screens); - return subUid; - }, - unsubscribe(token: number) { - subscribers.delete(token); - if (!subscribers.size) this.unregister(); - }, - unregister() { - Object.keys(responsiveMap).forEach((screen: Breakpoint) => { - const matchMediaQuery = responsiveMap[screen]; - const handler = this.matchHandlers[matchMediaQuery]; - handler?.mql.removeListener(handler?.listener); - }); - subscribers.clear(); - }, - register() { - Object.keys(responsiveMap).forEach((screen: Breakpoint) => { - const matchMediaQuery = responsiveMap[screen]; - const listener = ({ matches }: { matches: boolean }) => { - this.dispatch({ - ...screens, - [screen]: matches, - }); - }; - const mql = window.matchMedia(matchMediaQuery); - mql.addListener(listener); - this.matchHandlers[matchMediaQuery] = { - mql, - listener, - }; + // To avoid repeat create instance, we add `useMemo` here. + return React.useMemo(() => { + const subscribers = new Map(); + let subUid = -1; + let screens = {}; - listener(mql); - }); - }, -}; + return { + matchHandlers: {} as { + [prop: string]: { + mql: MediaQueryList; + listener: ((this: MediaQueryList, ev: MediaQueryListEvent) => any) | null; + }; + }, + dispatch(pointMap: ScreenMap) { + screens = pointMap; + subscribers.forEach((func) => func(screens)); + return subscribers.size >= 1; + }, + subscribe(func: SubscribeFunc): number { + if (!subscribers.size) this.register(); + subUid += 1; + subscribers.set(subUid, func); + func(screens); + return subUid; + }, + unsubscribe(paramToken: number) { + subscribers.delete(paramToken); + if (!subscribers.size) this.unregister(); + }, + unregister() { + Object.keys(responsiveMap).forEach((screen: Breakpoint) => { + const matchMediaQuery = responsiveMap[screen]; + const handler = this.matchHandlers[matchMediaQuery]; + handler?.mql.removeListener(handler?.listener); + }); + subscribers.clear(); + }, + register() { + Object.keys(responsiveMap).forEach((screen: Breakpoint) => { + const matchMediaQuery = responsiveMap[screen]; + const listener = ({ matches }: { matches: boolean }) => { + this.dispatch({ + ...screens, + [screen]: matches, + }); + }; + const mql = window.matchMedia(matchMediaQuery); + mql.addListener(listener); + this.matchHandlers[matchMediaQuery] = { + mql, + listener, + }; -export default responsiveObserve; + listener(mql); + }); + }, + responsiveMap, + }; + }, [token]); +} diff --git a/components/descriptions/index.tsx b/components/descriptions/index.tsx index ae04c14f6681..c7243e911231 100644 --- a/components/descriptions/index.tsx +++ b/components/descriptions/index.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { ConfigContext } from '../config-provider'; import { cloneElement } from '../_util/reactNode'; import type { Breakpoint, ScreenMap } from '../_util/responsiveObserve'; -import ResponsiveObserve, { responsiveArray } from '../_util/responsiveObserve'; +import useResponsiveObserve, { responsiveArray } from '../_util/responsiveObserve'; import warning from '../_util/warning'; import DescriptionsItem from './Item'; import Row from './Row'; @@ -65,7 +65,7 @@ function getFilledItem( } function getRows(children: React.ReactNode, column: number) { - const childNodes = toArray(children).filter(n => n); + const childNodes = toArray(children).filter((n) => n); const rows: React.ReactElement[][] = []; let tmpRow: React.ReactElement[] = []; @@ -132,9 +132,12 @@ function Descriptions({ const [screens, setScreens] = React.useState({}); const mergedColumn = getColumn(column, screens); + const [wrapSSR, hashId] = useStyle(prefixCls); + const responsiveObserve = useResponsiveObserve(); + // Responsive React.useEffect(() => { - const token = ResponsiveObserve.subscribe(newScreens => { + const token = responsiveObserve.subscribe((newScreens) => { if (typeof column !== 'object') { return; } @@ -142,7 +145,7 @@ function Descriptions({ }); return () => { - ResponsiveObserve.unsubscribe(token); + responsiveObserve.unsubscribe(token); }; }, []); diff --git a/components/grid/__tests__/index.test.tsx b/components/grid/__tests__/index.test.tsx index 47a19a5f114a..e882e0daab19 100644 --- a/components/grid/__tests__/index.test.tsx +++ b/components/grid/__tests__/index.test.tsx @@ -2,10 +2,37 @@ import React, { useState } from 'react'; import { Col, Row } from '..'; import mountTest from '../../../tests/shared/mountTest'; import rtlTest from '../../../tests/shared/rtlTest'; -import ResponsiveObserve from '../../_util/responsiveObserve'; import useBreakpoint from '../hooks/useBreakpoint'; import { render, act, fireEvent } from '../../../tests/utils'; +// Mock for `responsiveObserve` to test `unsubscribe` call +jest.mock('../../_util/responsiveObserve', () => { + const modules = jest.requireActual('../../_util/responsiveObserve'); + const originHook = modules.default; + + const useMockResponsiveObserver = (...args: any[]) => { + const entity = originHook(...args); + if (!entity.unsubscribe.mocked) { + const originUnsubscribe = entity.unsubscribe; + entity.unsubscribe = (...uArgs: any[]) => { + const inst = global as any; + inst.unsubscribeCnt = (inst.unsubscribeCnt || 0) + 1; + + originUnsubscribe.call(entity, ...uArgs); + }; + entity.unsubscribe.mocked = true; + } + + return entity; + }; + + return { + ...modules, + __esModule: true, + default: useMockResponsiveObserver, + }; +}); + describe('Grid', () => { mountTest(Row); mountTest(Col); @@ -13,8 +40,8 @@ describe('Grid', () => { rtlTest(Row); rtlTest(Col); - afterEach(() => { - ResponsiveObserve.unregister(); + beforeEach(() => { + (global as any).unsubscribeCnt = 0; }); it('should render Col', () => { @@ -48,14 +75,14 @@ describe('Grid', () => { it('when typeof gutter is object array in large screen', () => { jest.spyOn(window, 'matchMedia').mockImplementation( - query => + (query) => ({ addListener: (cb: (e: { matches: boolean }) => void) => { cb({ matches: query === '(min-width: 1200px)' }); }, removeListener: jest.fn(), matches: query === '(min-width: 1200px)', - } as any), + }) as any, ); const { container, asFragment } = render( @@ -88,13 +115,12 @@ describe('Grid', () => { expect(asFragment().firstChild).toMatchSnapshot(); }); - it('ResponsiveObserve.unsubscribe should be called when unmounted', () => { - const Unmount = jest.spyOn(ResponsiveObserve, 'unsubscribe'); + it('useResponsiveObserve.unsubscribe should be called when unmounted', () => { const { unmount } = render(); - act(() => { - unmount(); - }); - expect(Unmount).toHaveBeenCalled(); + const called: number = (global as any).unsubscribeCnt; + + unmount(); + expect((global as any).unsubscribeCnt).toEqual(called + 1); }); it('should work correct when gutter is object', () => { @@ -116,14 +142,14 @@ describe('Grid', () => { it('should work with useBreakpoint', () => { const matchMediaSpy = jest.spyOn(window, 'matchMedia'); matchMediaSpy.mockImplementation( - query => + (query) => ({ addListener: (cb: (e: { matches: boolean }) => void) => { cb({ matches: query === '(max-width: 575px)' }); }, removeListener: jest.fn(), matches: query === '(max-width: 575px)', - } as any), + }) as any, ); let screensVar; @@ -147,14 +173,14 @@ describe('Grid', () => { it('should align by responsive align prop', () => { const matchMediaSpy = jest.spyOn(window, 'matchMedia'); matchMediaSpy.mockImplementation( - query => + (query) => ({ addListener: (cb: (e: { matches: boolean }) => void) => { cb({ matches: query === '(max-width: 575px)' }); }, removeListener: jest.fn(), matches: query === '(max-width: 575px)', - } as any), + }) as any, ); const { container } = render(); expect(container.innerHTML).toContain('ant-row-middle'); @@ -167,14 +193,14 @@ describe('Grid', () => { it('should justify by responsive justify prop', () => { const matchMediaSpy = jest.spyOn(window, 'matchMedia'); matchMediaSpy.mockImplementation( - query => + (query) => ({ addListener: (cb: (e: { matches: boolean }) => void) => { cb({ matches: query === '(max-width: 575px)' }); }, removeListener: jest.fn(), matches: query === '(max-width: 575px)', - } as any), + }) as any, ); const { container } = render(); expect(container.innerHTML).toContain('ant-row-center'); diff --git a/components/grid/demo/responsive.md b/components/grid/demo/responsive.md index c6d6808680e3..3a9c629f71a6 100644 --- a/components/grid/demo/responsive.md +++ b/components/grid/demo/responsive.md @@ -7,7 +7,7 @@ title: ## zh-CN -参照 Bootstrap 的 [响应式设计](http://getbootstrap.com/css/#grid-media-queries),预设六个响应尺寸:`xs` `sm` `md` `lg` `xl` `xxl`。 +参照 Bootstrap 的 [响应式设计](http://getbootstrap.com/css/#grid-media-queries),预设六个响应尺寸:`xs` `sm` `md` `lg` `xl` `xxl`。 ## en-US diff --git a/components/grid/hooks/useBreakpoint.tsx b/components/grid/hooks/useBreakpoint.tsx index 4b8780c684b2..a66e2bd8908d 100644 --- a/components/grid/hooks/useBreakpoint.tsx +++ b/components/grid/hooks/useBreakpoint.tsx @@ -1,21 +1,22 @@ import { useEffect, useRef } from 'react'; import useForceUpdate from '../../_util/hooks/useForceUpdate'; import type { ScreenMap } from '../../_util/responsiveObserve'; -import ResponsiveObserve from '../../_util/responsiveObserve'; +import useResponsiveObserve from '../../_util/responsiveObserve'; function useBreakpoint(refreshOnChange: boolean = true): ScreenMap { const screensRef = useRef({}); const forceUpdate = useForceUpdate(); + const responsiveObserve = useResponsiveObserve(); useEffect(() => { - const token = ResponsiveObserve.subscribe(supportScreens => { + const token = responsiveObserve.subscribe((supportScreens) => { screensRef.current = supportScreens; if (refreshOnChange) { forceUpdate(); } }); - return () => ResponsiveObserve.unsubscribe(token); + return () => responsiveObserve.unsubscribe(token); }, []); return screensRef.current; diff --git a/components/grid/row.tsx b/components/grid/row.tsx index 1770dbe460cb..2ec2417c03c9 100644 --- a/components/grid/row.tsx +++ b/components/grid/row.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ConfigContext } from '../config-provider'; import useFlexGapSupport from '../_util/hooks/useFlexGapSupport'; import type { Breakpoint, ScreenMap } from '../_util/responsiveObserve'; -import ResponsiveObserve, { responsiveArray } from '../_util/responsiveObserve'; +import useResponsiveObserve, { responsiveArray } from '../_util/responsiveObserve'; import { tuple } from '../_util/type'; import RowContext from './RowContext'; @@ -99,9 +99,11 @@ const Row = React.forwardRef((props, ref) => { const gutterRef = React.useRef(gutter); + const responsiveObserve = useResponsiveObserve(); + // ================================== Effect ================================== React.useEffect(() => { - const token = ResponsiveObserve.subscribe(screen => { + const token = responsiveObserve.subscribe((screen) => { setCurScreens(screen); const currentGutter = gutterRef.current || 0; if ( @@ -112,7 +114,7 @@ const Row = React.forwardRef((props, ref) => { setScreens(screen); } }); - return () => ResponsiveObserve.unsubscribe(token); + return () => responsiveObserve.unsubscribe(token); }, []); // ================================== Render ==================================