@nocobase/client-v2 2.1.17 → 2.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -284,13 +284,36 @@ function SetIsMobileLayout(props: { isMobile: boolean; children: any; model?: Ad
284
284
  const flowEngine = useFlowEngine();
285
285
  const adminLayoutModel = props.model || flowEngine.getModel<AdminLayoutModel>(ADMIN_LAYOUT_MODEL_UID);
286
286
 
287
- useEffect(() => {
287
+ useLayoutEffect(() => {
288
288
  adminLayoutModel?.setIsMobileLayout(props.isMobile);
289
289
  }, [adminLayoutModel, props.isMobile]);
290
290
 
291
291
  return props.children;
292
292
  }
293
293
 
294
+ function AdminLayoutContentWithMobileState(props: {
295
+ isMobile: boolean;
296
+ model?: AdminLayoutModel;
297
+ onContentElementChange?: (element: HTMLDivElement | null) => void;
298
+ }) {
299
+ const modelRef = useRef(props.model);
300
+ const isMobileRef = useRef(props.isMobile);
301
+ const onContentElementChangeRef = useRef(props.onContentElementChange);
302
+
303
+ modelRef.current = props.model;
304
+ isMobileRef.current = props.isMobile;
305
+ onContentElementChangeRef.current = props.onContentElementChange;
306
+
307
+ const handleContentElementChange = useCallback((element: HTMLDivElement | null) => {
308
+ if (element) {
309
+ modelRef.current?.setIsMobileLayout(isMobileRef.current);
310
+ }
311
+ onContentElementChangeRef.current?.(element);
312
+ }, []);
313
+
314
+ return <AdminLayoutContent onContentElementChange={handleContentElementChange} />;
315
+ }
316
+
294
317
  const DesignerButtonMenuItem: FC<{ item: AdminLayoutMenuNode; fallbackParentRoute?: NocoBaseDesktopRoute }> = (
295
318
  props,
296
319
  ) => {
@@ -697,7 +720,11 @@ export const AdminLayoutComponent = observer((props: any) => {
697
720
  <SetIsMobileLayout isMobile={isMobile} model={adminLayoutModel}>
698
721
  <ConfigProvider theme={isMobile ? mobileTheme : theme}>
699
722
  <GlobalStyle />
700
- <AdminLayoutContent onContentElementChange={handleLayoutContentElementChange} />
723
+ <AdminLayoutContentWithMobileState
724
+ isMobile={isMobile}
725
+ model={adminLayoutModel}
726
+ onContentElementChange={handleLayoutContentElementChange}
727
+ />
701
728
  </ConfigProvider>
702
729
  </SetIsMobileLayout>
703
730
  );
@@ -11,10 +11,12 @@ import React from 'react';
11
11
  import { observable } from '@formily/reactive';
12
12
  import { act, render, waitFor } from '@testing-library/react';
13
13
  import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import { MemoryRouter } from 'react-router-dom';
14
15
 
15
- const { flowModelRendererSpy } = vi.hoisted(() => {
16
+ const { flowModelRendererSpy, adminLayoutContentEffectSpy } = vi.hoisted(() => {
16
17
  return {
17
18
  flowModelRendererSpy: vi.fn(),
19
+ adminLayoutContentEffectSpy: vi.fn(),
18
20
  };
19
21
  });
20
22
 
@@ -35,6 +37,52 @@ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
35
37
  };
36
38
  });
37
39
 
40
+ vi.mock('@ant-design/pro-layout', async () => {
41
+ const ReactActual = await import('react');
42
+ const RouteContext = ReactActual.createContext({ isMobile: true });
43
+ const ProLayout = (props: { children?: React.ReactNode }) => {
44
+ return ReactActual.createElement(RouteContext.Provider, { value: { isMobile: true } }, props.children);
45
+ };
46
+
47
+ return {
48
+ default: ProLayout,
49
+ RouteContext,
50
+ };
51
+ });
52
+
53
+ vi.mock('../AdminLayoutSlotModels', async (importOriginal) => {
54
+ const actual = await importOriginal<typeof import('../AdminLayoutSlotModels')>();
55
+ const ReactActual = await import('react');
56
+
57
+ return {
58
+ ...actual,
59
+ AdminLayoutContent: (props: { onContentElementChange?: (element: HTMLDivElement | null) => void }) => {
60
+ const bindContentRef = ReactActual.useCallback(
61
+ (element: HTMLDivElement | null) => {
62
+ props.onContentElementChange?.(element);
63
+ if (element) {
64
+ adminLayoutContentEffectSpy();
65
+ }
66
+ },
67
+ [props.onContentElementChange],
68
+ );
69
+
70
+ return ReactActual.createElement('div', { ref: bindContentRef, 'data-testid': 'admin-layout-content' });
71
+ },
72
+ };
73
+ });
74
+
75
+ vi.mock('../useApplications', () => ({
76
+ useApplications: () => ({
77
+ Component: null,
78
+ appList: [],
79
+ }),
80
+ }));
81
+
82
+ vi.mock('../AppListRender', () => ({
83
+ useAppListRender: () => undefined,
84
+ }));
85
+
38
86
  import {
39
87
  FlowEngine,
40
88
  FlowEngineProvider,
@@ -43,7 +91,7 @@ import {
43
91
  useFlowEngine,
44
92
  type FlowModel,
45
93
  } from '@nocobase/flow-engine';
46
- import { AdminLayoutModel, getAdminLayoutModel } from '..';
94
+ import { AdminLayoutComponent, AdminLayoutModel, getAdminLayoutModel } from '..';
47
95
  import { getLayoutPageRouteName, getLayoutPageViewRouteName } from '../../../../layout-manager/utils';
48
96
  import { TopbarActionModel } from '../../../models/topbar/TopbarActionModel';
49
97
  import { UserCenterTopbarActionModel } from '../../../models/topbar/UserCenterTopbarActionModel';
@@ -83,6 +131,7 @@ const TestAdminLayoutHost = (props) => {
83
131
  describe('AdminLayoutModel runtime', () => {
84
132
  beforeEach(() => {
85
133
  flowModelRendererSpy.mockClear();
134
+ adminLayoutContentEffectSpy.mockReset();
86
135
  });
87
136
 
88
137
  it('should create model via getAdminLayoutModel and update props on rerender', async () => {
@@ -142,6 +191,46 @@ describe('AdminLayoutModel runtime', () => {
142
191
  expect(model.context.layoutContentElement).toBeNull();
143
192
  });
144
193
 
194
+ it('should expose mobile layout state before initial layout content effects run', async () => {
195
+ const engine = new FlowEngine();
196
+ engine.context.defineProperty('routeRepository', {
197
+ value: {
198
+ listAccessible: vi.fn(() => []),
199
+ subscribe: vi.fn(),
200
+ unsubscribe: vi.fn(),
201
+ ensureAccessibleLoaded: vi.fn(() => Promise.resolve()),
202
+ moveRoute: vi.fn(() => Promise.resolve()),
203
+ },
204
+ });
205
+ const model = getAdminLayoutModel<AdminLayoutModel>(engine, { create: true });
206
+ if (!model) {
207
+ throw new Error('[NocoBase] Failed to create admin-layout-model.');
208
+ }
209
+ const modelLifecycle = model as unknown as { onMount: () => void; onUnmount: () => void };
210
+ modelLifecycle.onMount();
211
+ const observedMobileStates: boolean[] = [];
212
+ adminLayoutContentEffectSpy.mockImplementation(() => {
213
+ observedMobileStates.push(model.context.isMobileLayout);
214
+ });
215
+
216
+ const { unmount } = render(
217
+ <FlowEngineProvider engine={engine}>
218
+ <MemoryRouter initialEntries={['/admin/lrmg36pcahi']}>
219
+ <AdminLayoutComponent model={model} />
220
+ </MemoryRouter>
221
+ </FlowEngineProvider>,
222
+ );
223
+
224
+ await waitFor(() => {
225
+ expect(adminLayoutContentEffectSpy).toHaveBeenCalledTimes(1);
226
+ });
227
+
228
+ expect(observedMobileStates).toEqual([true]);
229
+
230
+ unmount();
231
+ modelLifecycle.onUnmount();
232
+ });
233
+
145
234
  it('should expose layout definition only while mounted', async () => {
146
235
  const engine = new FlowEngine();
147
236
  const { unmount } = render(
@@ -214,6 +303,54 @@ describe('AdminLayoutModel runtime', () => {
214
303
  });
215
304
  });
216
305
 
306
+ it('should keep route state when route page registers after route sync', async () => {
307
+ const engine = new FlowEngine();
308
+
309
+ render(
310
+ <FlowEngineProvider engine={engine}>
311
+ <TestAdminLayoutHost />
312
+ </FlowEngineProvider>,
313
+ );
314
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
315
+ const routeState = {
316
+ __nocobaseOpenViewInputArgs: {
317
+ popup: {
318
+ formData: {
319
+ start: '2026-06-24',
320
+ end: '2026-06-25',
321
+ },
322
+ },
323
+ },
324
+ };
325
+
326
+ act(() => {
327
+ model.syncLayoutRoute({
328
+ name: getLayoutPageViewRouteName('admin'),
329
+ pathname: '/admin/page-1/view/popup',
330
+ layoutBasePathname: '/admin',
331
+ state: routeState,
332
+ });
333
+ });
334
+
335
+ const coordinator = (model as any).getCoordinator();
336
+ const syncRoute = vi.spyOn(coordinator, 'syncRoute').mockImplementation(() => undefined);
337
+
338
+ act(() => {
339
+ model.registerRoutePage('page-1', {
340
+ active: true,
341
+ layoutContentElement: document.createElement('div'),
342
+ });
343
+ });
344
+
345
+ expect(syncRoute).toHaveBeenCalledWith(
346
+ expect.objectContaining({
347
+ pageUid: 'page-1',
348
+ pathname: '/admin/page-1/view/popup',
349
+ state: routeState,
350
+ }),
351
+ );
352
+ });
353
+
217
354
  it('should parse RunJS openView route params into layout route view stack state', async () => {
218
355
  const token = encodeOpenViewRouteState('popup', { mode: 'dialog', size: 'large' });
219
356
  if (!token) {
@@ -395,6 +532,226 @@ describe('AdminLayoutModel runtime', () => {
395
532
  });
396
533
  });
397
534
 
535
+ it('should restore layout route from router context when a route page registers after stale cleanup', async () => {
536
+ const engine = new FlowEngine();
537
+ engine.context.defineProperty('route', {
538
+ value: {
539
+ name: 'admin.page',
540
+ pathname: '/admin/page-1',
541
+ params: { name: 'page-1' },
542
+ },
543
+ });
544
+ engine.context.defineProperty('routeRepository', {
545
+ value: {
546
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
547
+ },
548
+ });
549
+
550
+ render(
551
+ <FlowEngineProvider engine={engine}>
552
+ <TestAdminLayoutHost />
553
+ </FlowEngineProvider>,
554
+ );
555
+
556
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
557
+ expect(model).toBeTruthy();
558
+ const routeLike = {
559
+ name: 'admin.page',
560
+ pathname: '/admin/page-1',
561
+ layoutRouteName: 'admin',
562
+ layoutBasePathname: '/admin',
563
+ };
564
+
565
+ act(() => {
566
+ model.syncLayoutRoute(routeLike);
567
+ model.clearLayoutRoute(routeLike);
568
+ });
569
+
570
+ expect(model.context.layoutRoute).toBeNull();
571
+ const syncRouteSpy = vi.spyOn(model.getCoordinator(), 'syncRoute');
572
+ syncRouteSpy.mockClear();
573
+
574
+ act(() => {
575
+ model.registerRoutePage('page-1', {
576
+ active: true,
577
+ });
578
+ });
579
+
580
+ await waitFor(() => {
581
+ expect(model.context.layoutRoute).toMatchObject({
582
+ type: 'page',
583
+ pageUid: 'page-1',
584
+ pathname: '/admin/page-1',
585
+ });
586
+ });
587
+ expect(model.context.currentRoute.title).toBe('page-1');
588
+ expect(syncRouteSpy).toHaveBeenLastCalledWith(
589
+ expect.objectContaining({
590
+ layoutRouteName: 'admin',
591
+ pageUid: 'page-1',
592
+ pathname: '/admin/page-1',
593
+ layoutBasePathname: '/admin',
594
+ }),
595
+ );
596
+ });
597
+
598
+ it('should restore layout route from a legacy dotted page view route name', async () => {
599
+ const engine = new FlowEngine();
600
+ engine.context.defineProperty('route', {
601
+ value: {
602
+ name: 'admin.page.view',
603
+ pathname: '/admin/page-1/view/popup',
604
+ params: { name: 'page-1' },
605
+ },
606
+ });
607
+ engine.context.defineProperty('routeRepository', {
608
+ value: {
609
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
610
+ },
611
+ });
612
+
613
+ render(
614
+ <FlowEngineProvider engine={engine}>
615
+ <TestAdminLayoutHost />
616
+ </FlowEngineProvider>,
617
+ );
618
+
619
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
620
+ expect(model).toBeTruthy();
621
+
622
+ act(() => {
623
+ model.registerRoutePage('page-1', {
624
+ active: true,
625
+ });
626
+ });
627
+
628
+ expect(model.context.layoutRoute).toMatchObject({
629
+ type: 'page',
630
+ pageUid: 'page-1',
631
+ pathname: '/admin/page-1/view/popup',
632
+ viewStack: [{ viewUid: 'page-1' }, { viewUid: 'popup' }],
633
+ });
634
+ expect(model.context.currentRoute.title).toBe('page-1');
635
+ });
636
+
637
+ it('should not restore layout route from another layout when a route page registers', async () => {
638
+ const engine = new FlowEngine();
639
+ engine.context.defineProperty('route', {
640
+ value: {
641
+ name: 'admin.settings.publicForms.page',
642
+ pathname: '/admin/settings/public-forms/form-1',
643
+ params: { name: 'form-1' },
644
+ layoutRouteName: 'admin.settings.publicForms',
645
+ layoutBasePathname: '/admin/settings/public-forms',
646
+ },
647
+ });
648
+ engine.context.defineProperty('routeRepository', {
649
+ value: {
650
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
651
+ },
652
+ });
653
+
654
+ render(
655
+ <FlowEngineProvider engine={engine}>
656
+ <TestAdminLayoutHost />
657
+ </FlowEngineProvider>,
658
+ );
659
+
660
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
661
+ expect(model).toBeTruthy();
662
+
663
+ act(() => {
664
+ model.registerRoutePage('form-1', {
665
+ active: true,
666
+ });
667
+ });
668
+
669
+ expect(model.context.layoutRoute).toBeNull();
670
+ expect(model.context.currentRoute).toEqual({});
671
+ });
672
+
673
+ it('should not restore layout route from a nested layout route without layoutRouteName', async () => {
674
+ const engine = new FlowEngine();
675
+ engine.context.defineProperty('route', {
676
+ value: {
677
+ name: 'admin.settings.publicForms.page',
678
+ pathname: '/admin/settings/public-forms/form-1',
679
+ params: { name: 'form-1' },
680
+ layoutBasePathname: '/admin/settings/public-forms',
681
+ },
682
+ });
683
+ engine.context.defineProperty('routeRepository', {
684
+ value: {
685
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
686
+ },
687
+ });
688
+
689
+ render(
690
+ <FlowEngineProvider engine={engine}>
691
+ <TestAdminLayoutHost />
692
+ </FlowEngineProvider>,
693
+ );
694
+
695
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
696
+ expect(model).toBeTruthy();
697
+
698
+ act(() => {
699
+ model.registerRoutePage('form-1', {
700
+ active: true,
701
+ });
702
+ });
703
+
704
+ expect(model.context.layoutRoute).toBeNull();
705
+ expect(model.context.currentRoute).toEqual({});
706
+ });
707
+
708
+ it('should ignore stale layout route cleanup after a route page has registered on the same path', async () => {
709
+ const engine = new FlowEngine();
710
+ engine.context.defineProperty('route', {
711
+ value: {
712
+ name: 'admin.page',
713
+ pathname: '/admin/page-1',
714
+ params: { name: 'page-1' },
715
+ },
716
+ });
717
+ engine.context.defineProperty('routeRepository', {
718
+ value: {
719
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
720
+ },
721
+ });
722
+
723
+ render(
724
+ <FlowEngineProvider engine={engine}>
725
+ <TestAdminLayoutHost />
726
+ </FlowEngineProvider>,
727
+ );
728
+
729
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
730
+ expect(model).toBeTruthy();
731
+ const staleRouteLike = {
732
+ name: 'admin.page',
733
+ pathname: '/admin/page-1',
734
+ layoutRouteName: 'admin',
735
+ layoutBasePathname: '/admin',
736
+ };
737
+
738
+ act(() => {
739
+ model.syncLayoutRoute(staleRouteLike);
740
+ model.clearLayoutRoute(staleRouteLike);
741
+ model.registerRoutePage('page-1', {
742
+ active: true,
743
+ });
744
+ model.clearLayoutRoute(staleRouteLike);
745
+ });
746
+
747
+ expect(model.context.layoutRoute).toMatchObject({
748
+ type: 'page',
749
+ pageUid: 'page-1',
750
+ pathname: '/admin/page-1',
751
+ });
752
+ expect(model.context.currentRoute.title).toBe('page-1');
753
+ });
754
+
398
755
  it('should keep pageActive in sync after non-active route page updates', async () => {
399
756
  const engine = new FlowEngine();
400
757
  engine.context.defineProperty('routeRepository', {
@@ -12,6 +12,7 @@ import { css } from '@emotion/css';
12
12
  import { createRoot } from 'react-dom/client';
13
13
  import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
14
14
  import Vditor from 'vditor';
15
+ import { removeMarkdownIframes, stripMarkdownIframes } from '../../../utils/markdownSanitize';
15
16
  import { useCDN } from './useCDN';
16
17
  import useStyle from './style';
17
18
 
@@ -51,8 +52,15 @@ function DisplayInner(props: { value: string; style?: CSSProperties; loadImages?
51
52
  Vditor.preview(containerRef.current, props.value ?? '', {
52
53
  mode: 'light',
53
54
  cdn,
54
- });
55
+ markdown: {
56
+ sanitize: true,
57
+ },
58
+ transform: stripMarkdownIframes,
59
+ })
60
+ .then(() => removeMarkdownIframes(containerRef.current))
61
+ .catch(() => removeMarkdownIframes(containerRef.current));
55
62
  setTimeout(() => {
63
+ removeMarkdownIframes(containerRef.current);
56
64
  containerRef.current?.querySelectorAll('img').forEach((img: HTMLImageElement) => {
57
65
  img.style.cursor = 'zoom-in';
58
66
  img.addEventListener('click', () => {
@@ -129,13 +137,16 @@ export const Display = (props) => {
129
137
  Vditor.md2html(props.value, {
130
138
  mode: 'light',
131
139
  cdn,
140
+ markdown: {
141
+ sanitize: true,
142
+ },
132
143
  })
133
144
  .then((html) => {
134
- setText(convertToText(html));
145
+ setText(convertToText(stripMarkdownIframes(html)));
135
146
  })
136
147
  .catch(() => setText(''));
137
148
  }
138
- }, [props.value, textOnly]);
149
+ }, [props.value, textOnly, cdn]);
139
150
 
140
151
  const isOverflowTooltip = useCallback(() => {
141
152
  if (!elRef.current) return false;
@@ -17,6 +17,7 @@ import React, { useEffect, useCallback, useLayoutEffect, useMemo, useRef, useSta
17
17
  import { useTranslation } from 'react-i18next';
18
18
  import Vditor from 'vditor';
19
19
  import 'vditor/dist/index.css';
20
+ import { stripMarkdownIframeTags, stripMarkdownIframes } from '../../../utils/markdownSanitize';
20
21
  import { useCDN } from './useCDN';
21
22
  import useStyle from './style';
22
23
 
@@ -102,13 +103,20 @@ const Edit = (props) => {
102
103
  if (!containerRef.current) return;
103
104
 
104
105
  const toolbarConfig = placeToolbarTooltipsBelow(toolbar ?? defaultToolbar);
106
+ const safeValue = stripMarkdownIframeTags(value ?? '');
105
107
  const vditor = new Vditor(containerRef.current, {
106
- value: value ?? '',
108
+ value: safeValue,
107
109
  lang,
108
110
  cache: { enable: false },
109
111
  undoDelay: 0,
110
112
  mode: props.mode || 'ir',
111
- preview: { math: { engine: 'KaTeX' } },
113
+ preview: {
114
+ markdown: {
115
+ sanitize: true,
116
+ },
117
+ math: { engine: 'KaTeX' },
118
+ transform: stripMarkdownIframes,
119
+ },
112
120
  toolbar: toolbarConfig,
113
121
  fullscreen: { index: 1200 },
114
122
  cdn,
@@ -117,7 +125,6 @@ const Edit = (props) => {
117
125
  after: () => {
118
126
  vdRef.current = vditor;
119
127
  setEditorReady(true); // Notify that the editor is ready
120
- vditor.setValue(value ?? '');
121
128
  if (disabled) {
122
129
  vditor.disabled();
123
130
  } else {
@@ -131,8 +138,12 @@ const Edit = (props) => {
131
138
  });
132
139
  }
133
140
  },
134
- input(value) {
135
- onChange(value);
141
+ input(nextValue) {
142
+ const safeNextValue = stripMarkdownIframeTags(nextValue);
143
+ if (safeNextValue !== nextValue) {
144
+ vditor.setValue(safeNextValue);
145
+ }
146
+ onChange(safeNextValue);
136
147
  },
137
148
  upload: {
138
149
  multiple: false,
@@ -229,8 +240,9 @@ const Edit = (props) => {
229
240
  useEffect(() => {
230
241
  if (editorReady && vdRef.current) {
231
242
  const editor = vdRef.current;
232
- if (value !== editor.getValue()) {
233
- editor.setValue(value ?? '');
243
+ const safeValue = stripMarkdownIframeTags(value ?? '');
244
+ if (safeValue !== editor.getValue()) {
245
+ editor.setValue(safeValue);
234
246
 
235
247
  const preArea = containerRef.current?.querySelector(
236
248
  'div.vditor-content > div.vditor-ir > pre',
@@ -9,13 +9,14 @@
9
9
 
10
10
  import _ from 'lodash';
11
11
  import { useEffect, useState } from 'react';
12
+ import { stripMarkdownIframes } from '../../../../utils/markdownSanitize';
12
13
 
13
14
  export const parseMarkdown = _.memoize(async (text: string) => {
14
15
  if (!text) {
15
16
  return text;
16
17
  }
17
18
  const m = await import('./md');
18
- return m.default.render(text);
19
+ return stripMarkdownIframes(m.default.render(text));
19
20
  });
20
21
 
21
22
  export function useParseMarkdown(text: string) {
@@ -9,56 +9,79 @@
9
9
 
10
10
  import { Input } from 'antd';
11
11
  import type { TextAreaProps } from 'antd/es/input';
12
- import type { TextAreaRef } from 'antd/es/input/TextArea';
13
- import React, { useEffect, useRef } from 'react';
12
+ import React, { useEffect, useRef, useState } from 'react';
14
13
  import { largeField, EditableItemModel } from '@nocobase/flow-engine';
15
14
  import { FieldModel } from '../base/FieldModel';
16
15
 
17
16
  function IMESafeTextArea(props: TextAreaProps) {
18
17
  const { value, onChange, onCompositionStart, onCompositionEnd, ...rest } = props;
19
- const textAreaRef = useRef<TextAreaRef>(null);
20
- const previousValueRef = useRef(value);
21
- const defaultValue = typeof value === 'bigint' ? String(value) : value;
18
+ const [textAreaValue, setTextAreaValue] = useState<TextAreaProps['value']>(() => normalizeTextAreaValue(value));
19
+ const textAreaValueRef = useRef<TextAreaProps['value']>(textAreaValue);
20
+ const pendingLocalValuesRef = useRef<TextAreaProps['value'][]>([]);
22
21
 
23
22
  useEffect(() => {
24
- if (Object.is(previousValueRef.current, value)) {
25
- return;
26
- }
27
- previousValueRef.current = value;
23
+ const nextValue = normalizeTextAreaValue(value);
24
+ const pendingValues = pendingLocalValuesRef.current;
25
+ const pendingIndex = pendingValues.findIndex((item) => Object.is(item, nextValue));
28
26
 
29
- const textArea = textAreaRef.current?.resizableTextArea?.textArea;
30
- if (textArea) {
31
- const nextValue = value == null ? '' : String(value);
32
- if (textArea.value !== nextValue) {
33
- textArea.value = nextValue;
27
+ if (pendingIndex >= 0) {
28
+ pendingValues.splice(0, pendingIndex + 1);
29
+ if (!Object.is(textAreaValueRef.current, nextValue)) {
30
+ return;
34
31
  }
35
32
  }
33
+
34
+ pendingValues.length = 0;
35
+ if (Object.is(textAreaValueRef.current, nextValue)) {
36
+ return;
37
+ }
38
+
39
+ textAreaValueRef.current = nextValue;
40
+ setTextAreaValue(nextValue);
36
41
  }, [value]);
37
42
 
38
43
  const getEventValue = (event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>) =>
39
44
  event.currentTarget.value;
40
45
 
46
+ const recordLocalValue = (
47
+ event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>,
48
+ ) => {
49
+ const nextValue = getEventValue(event);
50
+ const pendingValues = pendingLocalValuesRef.current;
51
+ pendingValues.push(nextValue);
52
+ if (pendingValues.length > 20) {
53
+ pendingValues.shift();
54
+ }
55
+ textAreaValueRef.current = nextValue;
56
+ setTextAreaValue(nextValue);
57
+ };
58
+
41
59
  return (
42
60
  <Input.TextArea
43
61
  {...rest}
44
- ref={textAreaRef}
45
- defaultValue={defaultValue}
62
+ value={textAreaValue}
46
63
  onChange={(event) => {
47
- previousValueRef.current = getEventValue(event);
64
+ recordLocalValue(event);
48
65
  onChange?.(event);
49
66
  }}
50
67
  onCompositionStart={(event) => {
51
- previousValueRef.current = getEventValue(event);
68
+ recordLocalValue(event);
52
69
  onCompositionStart?.(event);
53
70
  }}
54
71
  onCompositionEnd={(event) => {
55
- previousValueRef.current = getEventValue(event);
72
+ recordLocalValue(event);
56
73
  onCompositionEnd?.(event);
57
74
  }}
58
75
  />
59
76
  );
60
77
  }
61
78
 
79
+ function normalizeTextAreaValue(nextValue: unknown): TextAreaProps['value'] {
80
+ if (typeof nextValue === 'bigint') return String(nextValue);
81
+ if (nextValue == null) return '';
82
+ return nextValue as TextAreaProps['value'];
83
+ }
84
+
62
85
  @largeField()
63
86
  export class TextareaFieldModel extends FieldModel {
64
87
  render() {