Browse Source

Merge branch master into feature-merge-master

pull/43445/head
栗嘉男 1 year ago
parent
commit
6d547cac34
  1. 5
      .dumi/theme/builtins/Previewer/index.tsx
  2. 2
      .dumi/theme/common/ThemeSwitch/index.tsx
  3. 4
      components/button/button-group.tsx
  4. 4
      components/button/demo/chinese-chars-loading.tsx
  5. 19
      components/card/Card.tsx
  6. 11
      components/card/__tests__/index.test.tsx
  7. 3
      components/checkbox/Checkbox.tsx
  8. 2
      components/color-picker/__tests__/index.test.tsx
  9. 4
      components/form/__tests__/index.test.tsx
  10. 2
      components/form/demo/validate-only.tsx
  11. 4
      components/image/demo/preview-group-top-progress.tsx
  12. 2
      components/input/__tests__/index.test.tsx
  13. 2
      components/input/__tests__/textarea.test.tsx
  14. 4
      components/select/index.tsx
  15. 15
      components/slider/style/index.tsx
  16. 2
      components/table/__tests__/Table.filter.test.tsx
  17. 2
      components/tag/demo/draggable.tsx
  18. 4
      components/transfer/__tests__/index.test.tsx
  19. 2
      components/tree-select/style/index.ts
  20. 182
      docs/blog/suspense.en-US.md
  21. 180
      docs/blog/suspense.zh-CN.md

5
.dumi/theme/builtins/Previewer/index.tsx

@ -1,7 +1,6 @@
import type { FC } from 'react';
import React from 'react';
import type { IPreviewerProps } from 'dumi';
import { useTabMeta } from 'dumi';
import React from 'react';
import CodePreviewer from './CodePreviewer';
import DesignPreviewer from './DesignPreviewer';
@ -9,7 +8,7 @@ export interface AntdPreviewerProps extends IPreviewerProps {
originDebug?: IPreviewerProps['debug'];
}
const Previewer: FC<AntdPreviewerProps> = ({ ...props }) => {
const Previewer: React.FC<AntdPreviewerProps> = (props) => {
const tab = useTabMeta();
if (tab?.frontmatter.title === 'Design') {

2
.dumi/theme/common/ThemeSwitch/index.tsx

@ -14,7 +14,7 @@ export type ThemeSwitchProps = {
onChange: (value: ThemeName[]) => void;
};
const ThemeSwitch: React.FC<ThemeSwitchProps> = (props: ThemeSwitchProps) => {
const ThemeSwitch: React.FC<ThemeSwitchProps> = (props) => {
const { value = ['light'], onChange } = props;
const { token } = useSiteToken();
const { pathname, search } = useLocation();

4
components/button/button-group.tsx

@ -1,9 +1,9 @@
import classNames from 'classnames';
import * as React from 'react';
import warning from '../_util/warning';
import { ConfigContext } from '../config-provider';
import type { SizeType } from '../config-provider/SizeContext';
import { useToken } from '../theme/internal';
import warning from '../_util/warning';
export interface ButtonGroupProps {
size?: SizeType;
@ -13,7 +13,7 @@ export interface ButtonGroupProps {
children?: React.ReactNode;
}
export const GroupSizeContext = React.createContext<SizeType | undefined>(undefined);
export const GroupSizeContext = React.createContext<SizeType>(undefined);
const ButtonGroup: React.FC<ButtonGroupProps> = (props) => {
const { getPrefixCls, direction } = React.useContext(ConfigContext);

4
components/button/demo/chinese-chars-loading.tsx

@ -1,13 +1,13 @@
// @ts-nocheck
import React from 'react';
import { PoweroffOutlined } from '@ant-design/icons';
import { Button, Space } from 'antd';
import React from 'react';
const Text1 = () => '部署';
const Text2 = () => <span></span>;
const Text3 = () => 'Submit';
const App = () => (
const App: React.FC = () => (
<Space wrap>
<Button loading></Button>
<Button loading>

19
components/card/Card.tsx

@ -118,16 +118,15 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>((props, ref) => {
let head: React.ReactNode;
const mergedSize = useSize(customizeSize);
const tabSize = !mergedSize || mergedSize === 'default' ? 'large' : mergedSize;
const tabs =
tabList && tabList.length ? (
<Tabs
size={tabSize}
{...extraProps}
className={`${prefixCls}-head-tabs`}
onChange={onTabChange}
items={tabList.map(({ tab, ...item }) => ({ label: tab, ...item }))}
/>
) : null;
const tabs = tabList ? (
<Tabs
size={tabSize}
{...extraProps}
className={`${prefixCls}-head-tabs`}
onChange={onTabChange}
items={tabList.map(({ tab, ...item }) => ({ label: tab, ...item }))}
/>
) : null;
if (title || extra || tabs) {
head = (
<div className={`${prefixCls}-head`} style={headStyle}>

11
components/card/__tests__/index.test.tsx

@ -132,6 +132,17 @@ describe('Card', () => {
expect(cardRef.current).toHaveClass('ant-card');
});
it('should show tab when tabList is empty', () => {
const { container } = render(
<Card title="Card title" tabList={[]} tabProps={{ type: 'editable-card' }}>
<p>Card content</p>
</Card>,
);
expect(container.querySelector('.ant-tabs')).toBeTruthy();
expect(container.querySelector('.ant-tabs-nav-add')).toBeTruthy();
});
it('correct pass tabList props', () => {
const { container } = render(
<Card

3
components/checkbox/Checkbox.tsx

@ -152,7 +152,8 @@ const InternalCheckbox: React.ForwardRefRenderFunction<CheckboxRef, CheckboxProp
);
};
const Checkbox = React.forwardRef<unknown, CheckboxProps>(InternalCheckbox);
const Checkbox = React.forwardRef<CheckboxRef, CheckboxProps>(InternalCheckbox);
if (process.env.NODE_ENV !== 'production') {
Checkbox.displayName = 'Checkbox';
}

2
components/color-picker/__tests__/index.test.tsx

@ -55,7 +55,7 @@ describe('ColorPicker', () => {
});
it('Should component custom trigger work', async () => {
const App = () => {
const App: React.FC = () => {
const [color, setColor] = useState<Color | string>('hsb(215, 91%, 100%)');
const colorString = useMemo(
() => (typeof color === 'string' ? color : color.toHsbString()),

4
components/form/__tests__/index.test.tsx

@ -1641,7 +1641,7 @@ describe('Form', () => {
<DatePicker.YearPicker key="DatePicker.YearPicker" disabled={disabled} />,
<DatePicker.TimePicker key="DatePicker.TimePicker" disabled={disabled} />,
];
const App = () => <Form disabled>{renderComps(false)}</Form>;
const App: React.FC = () => <Form disabled>{renderComps(false)}</Form>;
const wrapper = render(<App />);
expect(wrapper.container.querySelectorAll('[disabled]').length).toBe(0);
@ -1803,7 +1803,7 @@ describe('Form', () => {
return <Input {...props} />;
};
const App = () => (
const App: React.FC = () => (
<Form>
<Form.Item>
<Form.Item name="test" label="test" rules={[{ len: 3, message: 'error.' }]}>

2
components/form/demo/validate-only.tsx

@ -26,7 +26,7 @@ const SubmitButton = ({ form }: { form: FormInstance }) => {
);
};
const App = () => {
const App: React.FC = () => {
const [form] = Form.useForm();
return (

4
components/image/demo/preview-group-top-progress.tsx

@ -1,7 +1,7 @@
import React from 'react';
import { Image } from 'antd';
import React from 'react';
const App = () => (
const App: React.FC = () => (
<Image.PreviewGroup
preview={{ countRender: (current, total) => `当前 ${current} / 总计 ${total}` }}
>

2
components/input/__tests__/index.test.tsx

@ -398,7 +398,7 @@ describe('Input allowClear', () => {
// https://github.com/ant-design/ant-design/issues/31927
it('should correctly when useState', () => {
const App = () => {
const App: React.FC = () => {
const [query, setQuery] = useState('');
return (
<Input

2
components/input/__tests__/textarea.test.tsx

@ -489,7 +489,7 @@ describe('TextArea allowClear', () => {
// https://github.com/ant-design/ant-design/issues/31927
it('should correctly when useState', () => {
const App = () => {
const App: React.FC = () => {
const [query, setQuery] = useState('');
return (
<TextArea

4
components/select/index.tsx

@ -43,7 +43,7 @@ export interface InternalSelectProps<
suffixIcon?: React.ReactNode;
size?: SizeType;
disabled?: boolean;
mode?: 'multiple' | 'tags' | 'SECRET_COMBOBOX_MODE_DO_NOT_USE';
mode?: 'multiple' | 'tags' | 'SECRET_COMBOBOX_MODE_DO_NOT_USE' | 'combobox';
bordered?: boolean;
}
@ -119,7 +119,7 @@ const InternalSelect = <
const mode = React.useMemo(() => {
const { mode: m } = props as InternalSelectProps<OptionType>;
if ((m as any) === 'combobox') {
if (m === 'combobox') {
return undefined;
}

15
components/slider/style/index.tsx

@ -66,8 +66,15 @@ interface SliderToken extends FullToken<'Slider'> {
// =============================== Base ===============================
const genBaseStyle: GenerateStyle<SliderToken> = (token) => {
const { componentCls, controlSize, dotSize, marginFull, marginPart, colorFillContentHover } =
token;
const {
componentCls,
antCls,
controlSize,
dotSize,
marginFull,
marginPart,
colorFillContentHover,
} = token;
return {
[componentCls]: {
@ -265,6 +272,10 @@ const genBaseStyle: GenerateStyle<SliderToken> = (token) => {
cursor: `not-allowed !important`,
},
},
[`&-tooltip ${antCls}-tooltip-inner`]: {
minWidth: 'unset',
},
},
};
};

2
components/table/__tests__/Table.filter.test.tsx

@ -1472,7 +1472,7 @@ describe('Table.filter', () => {
});
it('filtered should work after change', () => {
const App = () => {
const App: React.FC = () => {
const [filtered, setFiltered] = React.useState(true);
const columns = [
{

2
components/tag/demo/draggable.tsx

@ -43,7 +43,7 @@ const DraggableTag: FC<DraggableTagProps> = (props) => {
);
};
const App = () => {
const App: React.FC = () => {
const [items, setItems] = useState<Item[]>([
{
id: 1,

4
components/transfer/__tests__/index.test.tsx

@ -646,7 +646,7 @@ describe('Transfer', () => {
it('control mode select all should not throw warning', () => {
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const App = () => {
const App: React.FC = () => {
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const onSelectChange = (sourceSelectedKeys: string[], targetSelectedKeys: string[]) => {
@ -686,7 +686,7 @@ describe('immutable data', () => {
});
it('prevent error when reset data in some cases', () => {
const App = () => {
const App: React.FC = () => {
const [mockData, setMockData] = useState<DefaultRecordType[]>([]);
const [targetKeys, setTargetKeys] = useState<string[]>([]);

2
components/tree-select/style/index.ts

@ -30,7 +30,7 @@ const genBaseStyle: GenerateStyle<TreeSelectToken> = (token) => {
{
[treeCls]: {
borderRadius: 0,
'&-list-holder-inner': {
[`${treeCls}-list-holder-inner`]: {
alignItems: 'stretch',
[`${treeCls}-treenode`]: {

182
docs/blog/suspense.en-US.md

@ -0,0 +1,182 @@
---
title: Suspense breaks styles
date: 2023-07-07
author: zombieJ
---
We know that React 18 provides a `useInsertionEffect` hooks specifically for CSS-IN-JS, which has a faster timing priority than `useLayoutEffect`, so that the order of calls will not be affected by the order of writing:
```tsx
useLayoutEffect(() => {
console.log('layout effect');
}, []);
useInsertionEffect(() => {
console.log('insertion effect');
}, []);
// Console:
// - insertion effect
// - layout effect
```
In early `@ant-design/cssinjs` implementation, we did not choose `useInsertionEffect` because we needed to be compatible with React 17 version, but simulated the effect of inserting in advance by adding styles in the render phase:
```tsx
// pseudocode. Not used in real world
function useStyleInsertion(hash: string, counter: Record<string, number>) {
useMemo(() => {
if (!counter[hash]) {
// Insert only when current style not inserted
}
counter[hash] += 1;
}, [hash]);
useEffect(
() => () => {
counter[hash] -= 1;
if (!counter[hash]) {
// Remove if set to clear on destroy
}
},
[hash],
);
}
```
Above code will count the usage of styles, if the current style has not been inserted, it will insert the style in the render phase. Similarly, if the current style is configured to be unloaded when it is not in use, it will be cleared after the effect confirms the count. In addition, there is a similar logic that listens for changes in tokens, and when there are multiple tokens, it will clear all styles `<style />` corresponding to tokens that are no longer in use to avoid memory leaks caused by too many theme switches.
These code can run perfectly in React 17, and also run very well in React 18's StrictMode. `counter` always appears and disappears in pairs. But under Suspense, it may have problems.
## StrictMode
The StrictMode of React 18 is different from [React 17](https://17.reactjs.org/docs/strict-mode.html) in that it will be called multiple times in each phase to ensure that developers clean up the Effect:
````tsx
```tsx
const My = () => {
console.log('render');
useMemo(() => {
console.log('memo');
}, []);
useEffect(() => {
console.log('effect');
return () => {
console.log('effect cleanup');
};
}, []);
};
<StrictMode>
<My />
</StrictMode>;
// Console:
// - render
// - memo
// - render
// - memo
// - effect
// - effect cleanup
// - effect
````
With above sample, we can know that `counter` in StrictMode will be accumulated, but the final value will be correct (that is, each component will only be counted once):
- memo: 1
- memo: 2
- effect cleanup: 1
But StrictMode is just a simulation of Suspense. In the real scenario, the number of executions is not guaranteed to appear in pairs.
## Suspense
We use [umi](https://github.com/umijs/umi) for site development, which is split by page and loaded on demand by default. Display the loading state during the loading process through Suspense:
```tsx
<BrowserRoutes>
<Routs>
<Suspense fallback={<Loading />} />
</Routs>
</BrowserRoutes>
```
When switching pages, there is a chance that some styles will be lost when the page is switched back and forth:
<img width="300" alt="Fetch Failed" src="https://github.com/ant-design/ant-design/assets/5378891/f2bc49ed-9db6-4d7e-a5d3-8db0cda7b640" />
Part of the style lost in Page 1 is the style unique to Page 1 (some tokens are customized through ConfigProvider), and the style of Page 2 is the style common to Page 1 and Page 2.
With the style management logic we introduced at the beginning, Page 1 will be cleared all styles `<style />` corresponding to the token when Page 2 is rendered because it has styles corresponding to the independent token. This looks as expected, so the problem is that the style is not re-inserted when switching back to Page 1.
### Wrong Counter
With a series of breakpoints, we found that this problem is caused by the asynchronous nature of Suspense. It will call the component multiple times during the loading process, so the timing of the component style registration will also be called multiple times. And since our counter is in the render phase, the counter will be called multiple times under Suspense, which will cause the value of the counter to be incorrect:
- render: 0
- useMemo: 1
- render: 1
- useMemo: 2
- effect: 2
- Not like StrictMode, effect is not executed again, so effect cleanup will not be executed
Counter is not synchronized, so the token manager thinks that the style is no longer in use, so it performs batch cleaning, while the component style manager thinks that other components are still in use, so when re-entering Page 1, the style will not be re-inserted.
## useInsertionEffect
Obviously, due to its characteristics, we cannot use `useMemo` as a counter, it will not appear in pairs with `useEffect`. So we consider using `useInsertionEffect` to insert styles:
```tsx
// pseudocode. Not used in real world
useInsertionEffect(() => {
if (!counter[hash]) {
// Insert only when current style not inserted
}
counter[hash] += 1;
return () => {
counter[hash] -= 1;
if (!counter[hash]) {
// Remove if set to clear on destroy
}
};
}, [hash]);
```
And for React 17 version, it is downgraded to `useLayoutEffect`:
```tsx
const useMergedInsertionEffect = useInsertionEffect || useLayoutEffect;
useMergedInsertionEffect(() => {
// Same as above
}, [hash]);
```
With this modification, we found that React 17's CI was failed. After checking, we found that `useLayoutEffect` will have a timing problem:
```tsx
// Some logic measure dom size
useLayoutEffect(() => {
// This is not correct since style is not applied
const { clientHeight } = nodeRef.current;
}, []);
// Inject style
useLayoutEffect(() => {
// ...
}, [hash]);
```
Measure logic in `useLayoutEffect` is executed before injecting style, resulting in incorrect size information. It can also be predicted that this will have an impact on developers. So we have to compromise, and in React 17 version, it will be downgraded to the original `useMemo` insertion.
## Summary
Suspense brings rendering performance improvements, but it also makes timing very important. It is not the best way to only 'work on' StrictMode. Different logic is used for different React versions is not good choice since it will have timing problem. `render` will trigger from parent node to child node in turn, while `useInsertionEffect` is the opposite. However, from the perspective of antd, the component styles are independent of each other, so this problem will not affect us.

180
docs/blog/suspense.zh-CN.md

@ -0,0 +1,180 @@
---
title: Suspense 引发的样式丢失问题
date: 2023-07-07
author: zombieJ
---
我们知道,React 18 提供了一个专门为 CSS-IN-JS 使用的 `useInsertionEffect` hooks,它会比 `useLayoutEffect` 拥有更快的时序优先级,从而保证不会因为书写顺序而影响调用顺序的问题:
```tsx
useLayoutEffect(() => {
console.log('layout effect');
}, []);
useInsertionEffect(() => {
console.log('insertion effect');
}, []);
// Console:
// - insertion effect
// - layout effect
```
在早期 `@ant-design/cssinjs` 实现中,由于需要兼容 React 17 版本,我们并没有选择 `useInsertionEffect`,而是通过在 render 阶段添加样式的方式来模拟提前插入的效果:
```tsx
// pseudocode. Not used in real world
function useStyleInsertion(hash: string, counter: Record<string, number>) {
useMemo(() => {
if (!counter[hash]) {
// Insert only when current style not inserted
}
counter[hash] += 1;
}, [hash]);
useEffect(
() => () => {
counter[hash] -= 1;
if (!counter[hash]) {
// Remove if set to clear on destroy
}
},
[hash],
);
}
```
以上代码会对使用样式进行统计,如果发现当前样式没有被插入过,就会在 render 阶段插入样式,否则就不会插入。同样的,如果发现当前样式配置了未使用时卸载,则会在 effect 确认计数后清除。此外,还有一套类似的代码会监听 token 的变化,当存在多份 token 时会对不再使用的 token 对应的所有样式 `<style />` 进行清理,以避免过多的主题切换导致的内存泄漏。
这段代码在 React 17 可以完美运行,在 React 18 的 StrictMode 下也运行的十分正常。`counter` 总是成对出现与消失。但是它在 Suspense 下,就会有概率出现问题了。
## StrictMode
React 18 的 StrictMode 和 [React 17](https://17.reactjs.org/docs/strict-mode.html)不同的是,它会在各个阶段进行多次调用,从而确保开发者对 Effect 进行了清理:
```tsx
const My = () => {
console.log('render');
useMemo(() => {
console.log('memo');
}, []);
useEffect(() => {
console.log('effect');
return () => {
console.log('effect cleanup');
};
}, []);
};
<StrictMode>
<My />
</StrictMode>;
// Console:
// - render
// - memo
// - render
// - memo
// - effect
// - effect cleanup
// - effect
```
从上面的例子可以知道,`counter` 在 StrictMode 虽然会累加,但是最终会是正确的值(即每个组件只计算 1 次统计):
- memo: 1
- memo: 2
- effect cleanup: 1
但是 StrictMode 只是对 Suspense 的模拟。在真实场景下,执行次数并不会保证成对出现。
## Suspense
我们使用 [umi](https://github.com/umijs/umi) 进行站点开发,它默认按页拆包、按需加载。通过 Suspense 的方式在加载过程中显示 loading 状态:
```tsx
<BrowserRoutes>
<Routs>
<Suspense fallback={<Loading />} />
</Routs>
</BrowserRoutes>
```
在页面切换时,偶发出现页面往复切换时部分样式丢失的情况:
<img width="300" alt="Fetch Failed" src="https://github.com/ant-design/ant-design/assets/5378891/f2bc49ed-9db6-4d7e-a5d3-8db0cda7b640" />
其中 Page 1 丢失部分的样式为 Page 1 独有的样式(通过 ConfigProvider 定制了一些 token),而 Page 2 的样式则为 Page 1 与 Page 2 通用的样式。
在我们最初介绍的样式管理逻辑可以明白,Page 1 由于为独立 token 对应的样式,因而在 Page 2 渲染时会被清理掉所有的对应 token 的样式 `<style />`。这看起来是符合预期的,那么问题就出在了切回 Page 1 时样式没有被重新插入。
### 计数器错误
在经过一系列断点后,我们发现这个问题出现在计数器不同步之上。由于 Suspense 的特性,它会在加载过程中多次调用组件,所以组件样式注册的时机也会被调用多次。而由于我们的计数器是在 render 阶段进行的,所以在 Suspense 下,计数器会被多次调用,从而导致计数器的值不正确:
- render: 0
- useMemo: 1
- render: 1
- useMemo: 2
- effect: 2
- 不像 StrictMode,effect 并没有再次执行,所以 effect cleanup 也不会执行
计数器不同步导致 token 层面已经认为样式已经没有再使用所以进行了批量清理,而在组件样式层面则认为还有其他组件在使用,所以当重新进入 Page 1 时并不会重新插入样式。
## useInsertionEffect
显而易见,Suspense 由于其特性,我们不能通过 `useMemo` 来做计数器,它不会和 `useEffect` 成对出现。所以我们考虑需要使用 `useInsertionEffect` 来进行样式的插入:
```tsx
// pseudocode. Not used in real world
useInsertionEffect(() => {
if (!counter[hash]) {
// Insert only when current style not inserted
}
counter[hash] += 1;
return () => {
counter[hash] -= 1;
if (!counter[hash]) {
// Remove if set to clear on destroy
}
};
}, [hash]);
```
而对于 React 17 版本,则降级为 `useLayoutEffect`
```tsx
const useMergedInsertionEffect = useInsertionEffect || useLayoutEffect;
useMergedInsertionEffect(() => {
// Same as above
}, [hash]);
```
经过这样的修改后,我们发现 React 17 的 CI 挂了。在检查后,发现 `useLayoutEffect` 就会出现时序问题:
```tsx
// Some logic measure dom size
useLayoutEffect(() => {
// This is not correct since style is not applied
const { clientHeight } = nodeRef.current;
}, []);
// Inject style
useLayoutEffect(() => {
// ...
}, [hash]);
```
测量的 `useLayoutEffect` 先于注入样式执行,导致获取了错误的尺寸信息。也可以预测到这会对开发者产生影响。因而我们退而求其次,在 React 17 版本时会降级为原先的 `useMemo` 插入。
## 总结
Suspense 在带来渲染能力提升的同时也让时序变得十分重要,仅仅对 StrictMode 进行处理并不是一个最优的方式。针对不同的 React 版本使用不同的逻辑其实会存在不同版本之间的时序问题,`render` 会从父节点到子节点依次触发,而 `useInsertionEffect` 则相反。不过从 antd 角度来说,组件样式之间相互独立,所以这种时序问题并不会对我们产生影响。
Loading…
Cancel
Save