@nocobase/flow-engine 2.1.0-beta.2 → 2.1.0-beta.20

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.
Files changed (126) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/JSRunner.d.ts +10 -1
  4. package/lib/JSRunner.js +50 -5
  5. package/lib/ViewScopedFlowEngine.js +5 -1
  6. package/lib/components/FlowModelRenderer.d.ts +1 -1
  7. package/lib/components/FlowModelRenderer.js +10 -6
  8. package/lib/components/MobilePopup.js +6 -5
  9. package/lib/components/dnd/gridDragPlanner.js +6 -2
  10. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  11. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +48 -9
  12. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +19 -43
  13. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +339 -295
  14. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  15. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  16. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +272 -0
  17. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  18. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +247 -0
  19. package/lib/components/subModel/AddSubModelButton.js +27 -1
  20. package/lib/components/subModel/utils.js +2 -2
  21. package/lib/data-source/index.js +6 -0
  22. package/lib/executor/FlowExecutor.js +31 -8
  23. package/lib/flowContext.js +31 -1
  24. package/lib/flowEngine.d.ts +151 -1
  25. package/lib/flowEngine.js +389 -15
  26. package/lib/flowSettings.d.ts +14 -6
  27. package/lib/flowSettings.js +34 -6
  28. package/lib/lazy-helper.d.ts +14 -0
  29. package/lib/lazy-helper.js +71 -0
  30. package/lib/locale/en-US.json +1 -0
  31. package/lib/locale/index.d.ts +2 -0
  32. package/lib/locale/zh-CN.json +1 -0
  33. package/lib/models/flowModel.d.ts +2 -1
  34. package/lib/models/flowModel.js +28 -9
  35. package/lib/reactive/observer.js +46 -16
  36. package/lib/runjs-context/registry.d.ts +1 -1
  37. package/lib/runjs-context/setup.js +20 -12
  38. package/lib/runjs-context/snippets/index.js +13 -2
  39. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  40. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  41. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  42. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  43. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  44. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  45. package/lib/types.d.ts +47 -1
  46. package/lib/utils/index.d.ts +2 -2
  47. package/lib/utils/index.js +4 -0
  48. package/lib/utils/parsePathnameToViewParams.js +1 -1
  49. package/lib/utils/runjsTemplateCompat.js +1 -1
  50. package/lib/utils/runjsValue.js +41 -11
  51. package/lib/utils/schema-utils.d.ts +7 -1
  52. package/lib/utils/schema-utils.js +19 -0
  53. package/lib/views/FlowView.d.ts +7 -1
  54. package/lib/views/runViewBeforeClose.d.ts +10 -0
  55. package/lib/views/runViewBeforeClose.js +45 -0
  56. package/lib/views/useDialog.d.ts +2 -1
  57. package/lib/views/useDialog.js +20 -3
  58. package/lib/views/useDrawer.d.ts +2 -1
  59. package/lib/views/useDrawer.js +20 -3
  60. package/lib/views/usePage.d.ts +2 -1
  61. package/lib/views/usePage.js +10 -3
  62. package/package.json +6 -5
  63. package/src/JSRunner.ts +68 -4
  64. package/src/ViewScopedFlowEngine.ts +4 -0
  65. package/src/__tests__/JSRunner.test.ts +27 -1
  66. package/src/__tests__/flow-engine.test.ts +166 -0
  67. package/src/__tests__/flowContext.test.ts +65 -1
  68. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  69. package/src/__tests__/flowSettings.test.ts +94 -15
  70. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  71. package/src/__tests__/runjsContext.test.ts +16 -0
  72. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  73. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  74. package/src/__tests__/runjsSnippets.test.ts +21 -0
  75. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  76. package/src/components/FlowModelRenderer.tsx +12 -6
  77. package/src/components/MobilePopup.tsx +4 -2
  78. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  79. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  80. package/src/components/__tests__/gridDragPlanner.test.ts +88 -0
  81. package/src/components/dnd/gridDragPlanner.ts +8 -2
  82. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +63 -9
  83. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +468 -440
  84. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  85. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +95 -0
  86. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +609 -0
  87. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +358 -0
  88. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +281 -0
  89. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  90. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +142 -32
  91. package/src/components/subModel/utils.ts +1 -1
  92. package/src/data-source/index.ts +6 -0
  93. package/src/executor/FlowExecutor.ts +34 -9
  94. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  95. package/src/flowContext.ts +35 -3
  96. package/src/flowEngine.ts +445 -11
  97. package/src/flowSettings.ts +40 -6
  98. package/src/lazy-helper.tsx +57 -0
  99. package/src/locale/en-US.json +1 -0
  100. package/src/locale/zh-CN.json +1 -0
  101. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  102. package/src/models/flowModel.tsx +31 -10
  103. package/src/reactive/__tests__/observer.test.tsx +82 -0
  104. package/src/reactive/observer.tsx +87 -25
  105. package/src/runjs-context/registry.ts +1 -1
  106. package/src/runjs-context/setup.ts +22 -12
  107. package/src/runjs-context/snippets/index.ts +12 -1
  108. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  109. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  110. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  111. package/src/types.ts +60 -0
  112. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  113. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  114. package/src/utils/__tests__/utils.test.ts +62 -0
  115. package/src/utils/index.ts +2 -1
  116. package/src/utils/parsePathnameToViewParams.ts +2 -2
  117. package/src/utils/runjsTemplateCompat.ts +1 -1
  118. package/src/utils/runjsValue.ts +50 -11
  119. package/src/utils/schema-utils.ts +30 -1
  120. package/src/views/FlowView.tsx +11 -1
  121. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  122. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  123. package/src/views/runViewBeforeClose.ts +19 -0
  124. package/src/views/useDialog.tsx +25 -3
  125. package/src/views/useDrawer.tsx +25 -3
  126. package/src/views/usePage.tsx +12 -3
@@ -8,6 +8,10 @@
8
8
  */
9
9
 
10
10
  import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
11
+ import React from 'react';
12
+ import { createForm } from '@formily/core';
13
+ import { createSchemaField, FormProvider } from '@formily/react';
14
+ import { render, screen } from '@testing-library/react';
11
15
  import { FlowSettings } from '../flowSettings';
12
16
  import { DefaultSettingsIcon } from '../components/settings/wrappers/contextual/DefaultSettingsIcon';
13
17
  import { FlowModel } from '../models';
@@ -142,10 +146,10 @@ describe('FlowSettings', () => {
142
146
  expect(settingsItem?.sort).toBe(0);
143
147
  });
144
148
 
145
- test('should set up observable properties', () => {
149
+ test('should set up observable properties', async () => {
146
150
  // Test that enabled property is reactive
147
151
  const initialEnabled = flowSettings.enabled;
148
- flowSettings.enable();
152
+ await flowSettings.enable();
149
153
  expect(flowSettings.enabled).not.toBe(initialEnabled);
150
154
  expect(flowSettings.enabled).toBe(true);
151
155
  });
@@ -186,6 +190,43 @@ describe('FlowSettings', () => {
186
190
  flowSettings.registerComponents({});
187
191
  expect(Object.keys(flowSettings.components)).toHaveLength(0);
188
192
  });
193
+
194
+ test('should register component loaders and load component on render', async () => {
195
+ const loader = vi.fn(async () => ({
196
+ default: () => React.createElement('div', null, 'Lazy Flow Settings Component'),
197
+ }));
198
+
199
+ flowSettings.registerComponentLoaders({
200
+ DemoFlowSettingsLazyField: loader,
201
+ });
202
+
203
+ expect(loader).not.toHaveBeenCalled();
204
+
205
+ const SchemaField = createSchemaField();
206
+ const form = createForm();
207
+
208
+ render(
209
+ React.createElement(
210
+ FormProvider,
211
+ { form },
212
+ React.createElement(SchemaField, {
213
+ schema: {
214
+ type: 'object',
215
+ properties: {
216
+ demo: {
217
+ type: 'void',
218
+ 'x-component': 'DemoFlowSettingsLazyField',
219
+ },
220
+ },
221
+ },
222
+ components: flowSettings.components,
223
+ }),
224
+ ),
225
+ );
226
+
227
+ expect(await screen.findByText('Lazy Flow Settings Component')).toBeInTheDocument();
228
+ expect(loader).toHaveBeenCalledTimes(1);
229
+ });
189
230
  });
190
231
 
191
232
  describe('Scope Registration', () => {
@@ -228,30 +269,68 @@ describe('FlowSettings', () => {
228
269
  });
229
270
 
230
271
  describe('Enable/Disable Functionality', () => {
231
- test('should enable flow settings', () => {
272
+ test('should enable flow settings', async () => {
232
273
  expect(flowSettings.enabled).toBe(false);
233
274
 
234
- flowSettings.enable();
275
+ await flowSettings.enable();
235
276
 
236
277
  expect(flowSettings.enabled).toBe(true);
237
278
  });
238
279
 
239
- test('should disable flow settings', () => {
240
- flowSettings.enable();
280
+ test('should preload model loaders before enabling flow settings', async () => {
281
+ const preloadSpy = vi.spyOn(engine, 'preloadModelLoaders').mockResolvedValue({
282
+ requested: [],
283
+ loaded: [],
284
+ failed: [],
285
+ });
286
+
287
+ await flowSettings.enable();
288
+
289
+ expect(preloadSpy).toHaveBeenCalledTimes(1);
241
290
  expect(flowSettings.enabled).toBe(true);
291
+ });
242
292
 
243
- flowSettings.disable();
293
+ test('should preload model loaders before force enabling flow settings', async () => {
294
+ const preloadSpy = vi.spyOn(engine, 'preloadModelLoaders').mockResolvedValue({
295
+ requested: [],
296
+ loaded: [],
297
+ failed: [],
298
+ });
299
+
300
+ await flowSettings.forceEnable();
301
+
302
+ expect(preloadSpy).toHaveBeenCalledTimes(1);
303
+ expect(flowSettings.enabled).toBe(true);
304
+ });
305
+
306
+ test('should disable flow settings', async () => {
307
+ await flowSettings.enable();
308
+ expect(flowSettings.enabled).toBe(true);
309
+
310
+ await flowSettings.disable();
244
311
 
245
312
  expect(flowSettings.enabled).toBe(false);
246
313
  });
247
314
 
248
- test('should handle multiple enable/disable calls', () => {
249
- flowSettings.enable();
250
- flowSettings.enable();
315
+ test('should handle multiple enable/disable calls', async () => {
316
+ await flowSettings.enable();
317
+ await flowSettings.enable();
251
318
  expect(flowSettings.enabled).toBe(true);
252
319
 
253
- flowSettings.disable();
254
- flowSettings.disable();
320
+ await flowSettings.disable();
321
+ await flowSettings.disable();
322
+ expect(flowSettings.enabled).toBe(false);
323
+ });
324
+
325
+ test('forceDisable should clear force-enabled state and disable flow settings', async () => {
326
+ await flowSettings.forceEnable();
327
+ expect(flowSettings.enabled).toBe(true);
328
+
329
+ await flowSettings.forceDisable();
330
+
331
+ expect(flowSettings.enabled).toBe(false);
332
+
333
+ await flowSettings.disable();
255
334
  expect(flowSettings.enabled).toBe(false);
256
335
  });
257
336
  });
@@ -512,7 +591,7 @@ describe('FlowSettings', () => {
512
591
  });
513
592
 
514
593
  describe('Complex Integration Scenarios', () => {
515
- test('should maintain state consistency during multiple operations', () => {
594
+ test('should maintain state consistency during multiple operations', async () => {
516
595
  // Initialize with components and scopes
517
596
  const TestComponent = () => 'TestComponent';
518
597
  const testScope = () => 'testScope';
@@ -528,7 +607,7 @@ describe('FlowSettings', () => {
528
607
  });
529
608
 
530
609
  // Enable/disable
531
- flowSettings.enable();
610
+ await flowSettings.enable();
532
611
  expect(flowSettings.enabled).toBe(true);
533
612
 
534
613
  // Verify all state is maintained
@@ -536,7 +615,7 @@ describe('FlowSettings', () => {
536
615
  expect(flowSettings.scopes.testScope).toBe(testScope);
537
616
  expect(flowSettings.getToolbarItems().find((item) => item.key === 'integration-test')).toBeDefined();
538
617
 
539
- flowSettings.disable();
618
+ await flowSettings.disable();
540
619
  expect(flowSettings.enabled).toBe(false);
541
620
 
542
621
  // State should still be maintained after disable
@@ -14,7 +14,7 @@ import { FlowEngine } from '../flowEngine';
14
14
  import { FlowModel, ModelRenderMode } from '../models/flowModel';
15
15
 
16
16
  describe('FlowModel.renderHiddenInConfig', () => {
17
- it('renders via renderHiddenInConfig when hidden and config enabled (React element mode, mounted)', () => {
17
+ it('renders via renderHiddenInConfig when hidden and config enabled (React element mode, mounted)', async () => {
18
18
  class ElemModel extends FlowModel {
19
19
  render() {
20
20
  return <div data-testid="content">Content</div>;
@@ -28,14 +28,14 @@ describe('FlowModel.renderHiddenInConfig', () => {
28
28
  const model = new ElemModel({ uid: 'elem-1', flowEngine: engine });
29
29
 
30
30
  // runtime hidden => mounted result should be empty (no content/hidden)
31
- engine.flowSettings.disable();
31
+ await engine.flowSettings.disable();
32
32
  model.setHidden(true);
33
33
  const { container, unmount, rerender } = render(model.render() as React.ReactElement);
34
34
  expect(screen.queryByTestId('content')).toBeNull();
35
35
  expect(screen.queryByTestId('hidden')).toBeNull();
36
36
 
37
37
  // config enabled + hidden => should show renderHiddenInConfig result
38
- engine.flowSettings.enable();
38
+ await engine.flowSettings.enable();
39
39
  rerender(model.render() as React.ReactElement);
40
40
  expect(screen.getByTestId('hidden').textContent).toBe('HiddenViaAPI');
41
41
 
@@ -46,7 +46,7 @@ describe('FlowModel.renderHiddenInConfig', () => {
46
46
  unmount();
47
47
  cleanup();
48
48
  });
49
- it('returns a render function when hidden and config enabled (RenderFunction mode)', () => {
49
+ it('returns a render function when hidden and config enabled (RenderFunction mode)', async () => {
50
50
  class FuncModel extends FlowModel {
51
51
  static override renderMode = ModelRenderMode.RenderFunction;
52
52
  render() {
@@ -63,13 +63,13 @@ describe('FlowModel.renderHiddenInConfig', () => {
63
63
  const model = engine.createModel({ use: 'FuncModel' }) as FuncModel;
64
64
 
65
65
  // runtime hidden => null
66
- engine.flowSettings.disable();
66
+ await engine.flowSettings.disable();
67
67
  model.setHidden(true);
68
68
  const runtimeHidden = model.render();
69
69
  expect(runtimeHidden).toBeNull();
70
70
 
71
71
  // config enabled + hidden => renderHiddenInConfig (function)
72
- engine.flowSettings.enable();
72
+ await engine.flowSettings.enable();
73
73
  const cfgHidden = model.render();
74
74
  expect(typeof cfgHidden).toBe('function');
75
75
  const cellNode = (cfgHidden as any)();
@@ -29,11 +29,16 @@ describe('flowRunJSContext registry and doc', () => {
29
29
  expect(RunJSContextRegistry['resolve']('v1' as any, '*')).toBeTruthy();
30
30
  });
31
31
 
32
+ it('should register v2 mapping', () => {
33
+ expect(RunJSContextRegistry['resolve']('v2' as any, '*')).toBeTruthy();
34
+ });
35
+
32
36
  it('should register all context types', () => {
33
37
  const contextTypes = [
34
38
  'JSBlockModel',
35
39
  'JSFieldModel',
36
40
  'JSItemModel',
41
+ 'JSItemActionModel',
37
42
  'JSColumnModel',
38
43
  'FormJSFieldItemModel',
39
44
  'JSRecordActionModel',
@@ -44,12 +49,22 @@ describe('flowRunJSContext registry and doc', () => {
44
49
  const ctor = RunJSContextRegistry['resolve']('v1' as any, modelClass);
45
50
  expect(ctor).toBeTruthy();
46
51
  });
52
+
53
+ contextTypes.forEach((modelClass) => {
54
+ const ctor = RunJSContextRegistry['resolve']('v2' as any, modelClass);
55
+ expect(ctor).toBeTruthy();
56
+ });
47
57
  });
48
58
 
49
59
  it('should expose scene metadata for contexts', () => {
50
60
  expect(getRunJSScenesForModel('JSBlockModel', 'v1')).toEqual(['block']);
51
61
  expect(getRunJSScenesForModel('JSFieldModel', 'v1')).toEqual(['detail']);
62
+ expect(getRunJSScenesForModel('JSItemActionModel', 'v1')).toEqual(['table']);
63
+ expect(getRunJSScenesForModel('JSBlockModel', 'v2')).toEqual(['block']);
64
+ expect(getRunJSScenesForModel('JSFieldModel', 'v2')).toEqual(['detail']);
65
+ expect(getRunJSScenesForModel('JSItemActionModel', 'v2')).toEqual(['table']);
52
66
  expect(getRunJSScenesForModel('UnknownModel', 'v1')).toEqual([]);
67
+ expect(getRunJSScenesForModel('UnknownModel', 'v2')).toEqual([]);
53
68
  });
54
69
 
55
70
  it('should only execute once (idempotent)', async () => {
@@ -175,6 +190,7 @@ describe('flowRunJSContext registry and doc', () => {
175
190
  const ctx = new FlowContext();
176
191
  ctx.defineProperty('model', { value: { constructor: { name: 'JSColumnModel' } } });
177
192
  expect(getRunJSScenesForContext(ctx as any, { version: 'v1' })).toEqual(['table']);
193
+ expect(getRunJSScenesForContext(ctx as any, { version: 'v2' })).toEqual(['table']);
178
194
  });
179
195
 
180
196
  it('JSBlockModel context should have element property in doc', () => {
@@ -186,6 +186,7 @@ describe('RunJS Context Runtime Behavior', () => {
186
186
  'JSBlockModel',
187
187
  'JSFieldModel',
188
188
  'JSItemModel',
189
+ 'JSItemActionModel',
189
190
  'JSColumnModel',
190
191
  'FormJSFieldItemModel',
191
192
  'JSRecordActionModel',
@@ -237,6 +238,7 @@ describe('RunJS Context Runtime Behavior', () => {
237
238
  'JSBlockModel',
238
239
  'JSFieldModel',
239
240
  'JSItemModel',
241
+ 'JSItemActionModel',
240
242
  'JSColumnModel',
241
243
  'FormJSFieldItemModel',
242
244
  'JSRecordActionModel',
@@ -36,6 +36,29 @@ describe('ctx.runjs preprocessTemplates default', () => {
36
36
  expect(r.value).toBe('{{ctx.user.id}}');
37
37
  });
38
38
 
39
+ it('disables template preprocess by default for version v2', async () => {
40
+ const engine = new FlowEngine();
41
+ const ctx = engine.context as any;
42
+ ctx.defineProperty('user', { value: { id: 123 } });
43
+
44
+ const r = await ctx.runjs('return "{{ctx.user.id}}";', undefined, { version: 'v2' });
45
+ expect(r.success).toBe(true);
46
+ expect(r.value).toBe('{{ctx.user.id}}');
47
+ });
48
+
49
+ it('keeps explicit preprocessTemplates override higher priority than version', async () => {
50
+ const engine = new FlowEngine();
51
+ const ctx = engine.context as any;
52
+ ctx.defineProperty('user', { value: { id: 123 } });
53
+
54
+ const r = await ctx.runjs('return "{{ctx.user.id}}";', undefined, {
55
+ version: 'v2',
56
+ preprocessTemplates: true,
57
+ });
58
+ expect(r.success).toBe(true);
59
+ expect(r.value).toBe('123');
60
+ });
61
+
39
62
  it('does not double-preprocess already prepared code', async () => {
40
63
  const engine = new FlowEngine();
41
64
  const ctx = engine.context as any;
@@ -112,6 +112,27 @@ describe('RunJS Snippets', () => {
112
112
  expect(multiScene?.scenes).toEqual(expect.arrayContaining(['detail', 'table']));
113
113
  expect(multiScene?.groups).toEqual(expect.arrayContaining(['scene/detail', 'scene/table']));
114
114
  });
115
+
116
+ it('should expose new style snippets for matching contexts', async () => {
117
+ const tableSnippets = await listSnippetsForContext('JSColumnRunJSContext', 'v1', 'zh-CN');
118
+ const fieldSnippets = await listSnippetsForContext('FormJSFieldItemRunJSContext', 'v1', 'zh-CN');
119
+ const detailEventSnippets = await listSnippetsForContext('DetailsItemModel', 'v1', 'zh-CN');
120
+ const tableEventSnippets = await listSnippetsForContext('TableColumnModel', 'v1', 'zh-CN');
121
+
122
+ const tableStyle = tableSnippets.find((s) => s.ref === 'scene/table/set-cell-style');
123
+ expect(tableStyle?.name).toBe('表格字段样式设置');
124
+ expect(tableStyle?.body).toContain('ctx.model.props.onCell');
125
+ expect(tableStyle?.scenes).toEqual(['tableFieldEvent']);
126
+
127
+ const fieldStyle = fieldSnippets.find((s) => s.ref === 'scene/detail/set-field-style');
128
+ expect(fieldStyle?.name).toBe('设置表单项/详情项样式');
129
+ expect(fieldStyle?.body).toContain('ctx.model.props.style');
130
+ expect(fieldStyle?.scenes).toEqual(expect.arrayContaining(['detailFieldEvent', 'formFieldEvent']));
131
+ expect(fieldStyle?.groups).toEqual(expect.arrayContaining(['scene/detail', 'scene/form']));
132
+
133
+ expect(detailEventSnippets.some((s) => s.ref === 'scene/detail/set-field-style')).toBe(true);
134
+ expect(tableEventSnippets.some((s) => s.ref === 'scene/table/set-cell-style')).toBe(true);
135
+ });
115
136
  });
116
137
 
117
138
  describe('New snippets', () => {
@@ -256,11 +256,11 @@ describe('ViewScopedFlowEngine', () => {
256
256
 
257
257
  // Both children should return null from hydration because parent has flowSettingsEnabled
258
258
  // This is the bug fix: previously only children with their own flowSettingsEnabled would return null
259
- const result1 = (scoped as any).hydrateModelFromPreviousEngines({
259
+ const result1 = await (scoped as any).hydrateModelFromPreviousEngines({
260
260
  parentId: 'parent-with-settings',
261
261
  subKey: 'popup',
262
262
  });
263
- const result2 = (scoped as any).hydrateModelFromPreviousEngines({
263
+ const result2 = await (scoped as any).hydrateModelFromPreviousEngines({
264
264
  parentId: 'parent-with-settings',
265
265
  subKey: 'items',
266
266
  });
@@ -298,7 +298,7 @@ describe('ViewScopedFlowEngine', () => {
298
298
  const scoped = createViewScopedEngine(root);
299
299
 
300
300
  // Call the private method hydrateModelFromPreviousEngines directly
301
- const result = (scoped as any).hydrateModelFromPreviousEngines({
301
+ const result = await (scoped as any).hydrateModelFromPreviousEngines({
302
302
  parentId: 'parent-normal',
303
303
  subKey: 'content',
304
304
  });
@@ -69,7 +69,7 @@ export interface FlowModelRendererProps {
69
69
  showBackground?: boolean;
70
70
  showBorder?: boolean;
71
71
  showDragHandle?: boolean;
72
- /** 自定义工具栏样式 */
72
+ /** 自定义工具栏样式,`top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
73
73
  style?: React.CSSProperties;
74
74
  /**
75
75
  * @default 'inside'
@@ -112,6 +112,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
112
112
  showBackground?: boolean;
113
113
  showBorder?: boolean;
114
114
  showDragHandle?: boolean;
115
+ /** `top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
115
116
  style?: React.CSSProperties;
116
117
  /**
117
118
  * @default 'inside'
@@ -126,6 +127,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
126
127
  settingsMenuLevel?: number;
127
128
  extraToolbarItems?: ToolbarItemConfig[];
128
129
  fallback?: React.ReactNode;
130
+ useCache?: boolean;
129
131
  }> = observer(
130
132
  ({
131
133
  model,
@@ -138,12 +140,12 @@ const FlowModelRendererWithAutoFlows: React.FC<{
138
140
  settingsMenuLevel,
139
141
  extraToolbarItems,
140
142
  fallback,
143
+ useCache,
141
144
  }) => {
142
145
  // hidden 占位由模型自身处理;无需在此注入
143
-
144
146
  const { loading: pending, error: autoFlowsError } = useApplyAutoFlows(model, inputArgs, {
145
147
  throwOnError: false,
146
- useCache: model.context.useCache,
148
+ useCache,
147
149
  });
148
150
  // 将错误下沉到 model 实例上,供内容层读取(类型安全的 WeakMap 存储)
149
151
  setAutoFlowError(model, autoFlowsError || null);
@@ -182,6 +184,7 @@ const FlowModelRendererCore: React.FC<{
182
184
  showBackground?: boolean;
183
185
  showBorder?: boolean;
184
186
  showDragHandle?: boolean;
187
+ /** `top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
185
188
  style?: React.CSSProperties;
186
189
  /**
187
190
  * @default 'inside'
@@ -346,13 +349,15 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
346
349
  extraToolbarItems,
347
350
  useCache,
348
351
  }) => {
352
+ const resolvedUseCache = typeof useCache === 'boolean' ? useCache : model?.context?.useCache;
353
+
349
354
  useEffect(() => {
350
- if (model?.context) {
355
+ if (model?.context && typeof resolvedUseCache !== 'undefined') {
351
356
  model.context.defineProperty('useCache', {
352
- value: typeof useCache === 'boolean' ? useCache : model.context.useCache,
357
+ value: resolvedUseCache,
353
358
  });
354
359
  }
355
- }, [model?.context, useCache]);
360
+ }, [model?.context, resolvedUseCache]);
356
361
 
357
362
  if (!model || typeof model.render !== 'function') {
358
363
  // 可以选择渲染 null 或者一个错误/提示信息
@@ -373,6 +378,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
373
378
  settingsMenuLevel={settingsMenuLevel}
374
379
  extraToolbarItems={extraToolbarItems}
375
380
  fallback={fallback}
381
+ useCache={resolvedUseCache}
376
382
  />
377
383
  );
378
384
 
@@ -8,11 +8,13 @@
8
8
  */
9
9
 
10
10
  import { ConfigProvider } from 'antd';
11
- import { Popup } from 'antd-mobile';
12
11
  import React, { FC, ReactNode, useMemo } from 'react';
13
- import { CloseOutline } from 'antd-mobile-icons';
14
12
  import { useMobileActionDrawerStyle } from './MobilePopup.style';
15
13
  import { useTranslation } from 'react-i18next';
14
+ import { lazy } from '../lazy-helper';
15
+
16
+ const { Popup } = lazy(() => import('antd-mobile'), 'Popup');
17
+ const { CloseOutline } = lazy(() => import('antd-mobile-icons'), 'CloseOutline');
16
18
 
17
19
  interface MobilePopupProps {
18
20
  title?: string;
@@ -38,9 +38,8 @@ describe('FlowModelRenderer', () => {
38
38
  test('should pass useCache to useApplyAutoFlows and set it on context', async () => {
39
39
  const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={true} />);
40
40
 
41
- // Check if dispatchEvent was called with useCache: true
42
- // useApplyAutoFlows calls dispatchEvent('beforeRender', inputArgs, { useCache })
43
41
  await waitFor(() => {
42
+ expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
44
43
  expect(model.dispatchEvent).toHaveBeenCalledWith(
45
44
  'beforeRender',
46
45
  undefined,
@@ -58,6 +57,7 @@ describe('FlowModelRenderer', () => {
58
57
  const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={false} />);
59
58
 
60
59
  await waitFor(() => {
60
+ expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
61
61
  expect(model.dispatchEvent).toHaveBeenCalledWith(
62
62
  'beforeRender',
63
63
  undefined,
@@ -74,6 +74,7 @@ describe('FlowModelRenderer', () => {
74
74
  const { unmount } = renderWithProvider(<FlowModelRenderer model={model} />);
75
75
 
76
76
  await waitFor(() => {
77
+ expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
77
78
  expect(model.dispatchEvent).toHaveBeenCalledWith(
78
79
  'beforeRender',
79
80
  undefined,
@@ -86,4 +87,66 @@ describe('FlowModelRenderer', () => {
86
87
 
87
88
  unmount();
88
89
  });
90
+
91
+ test('should clear stale beforeRender state after unmount when reusing the same model', async () => {
92
+ const statefulEngine = new FlowEngine();
93
+ const onMountSpy = vi.fn();
94
+ const onUnmountSpy = vi.fn();
95
+
96
+ class StatefulModel extends FlowModel {
97
+ render(): any {
98
+ return <div>Stateful Content</div>;
99
+ }
100
+
101
+ protected onMount(): void {
102
+ onMountSpy();
103
+ }
104
+
105
+ protected onUnmount(): void {
106
+ onUnmountSpy();
107
+ }
108
+ }
109
+
110
+ const statefulModel = new StatefulModel({
111
+ uid: 'stateful-model',
112
+ flowEngine: statefulEngine,
113
+ });
114
+ const executorSpy = vi.spyOn((statefulEngine as any).executor, 'dispatchEvent').mockResolvedValue([]);
115
+
116
+ const firstRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
117
+ await waitFor(() => {
118
+ expect(executorSpy).toHaveBeenCalledTimes(1);
119
+ });
120
+ await waitFor(() => {
121
+ expect(onMountSpy).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ firstRender.unmount();
125
+ await waitFor(() => {
126
+ expect(onUnmountSpy).toHaveBeenCalledTimes(1);
127
+ });
128
+
129
+ executorSpy.mockClear();
130
+ statefulModel.setStepParams('anyFlow', 'anyStep', { x: 1 });
131
+ await new Promise((resolve) => setTimeout(resolve, 150));
132
+ expect(executorSpy.mock.calls.length).toBe(0);
133
+
134
+ const secondRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
135
+ await waitFor(() => {
136
+ expect(executorSpy).toHaveBeenCalledTimes(1);
137
+ });
138
+ await waitFor(() => {
139
+ expect(onMountSpy).toHaveBeenCalledTimes(2);
140
+ });
141
+ const [target, eventName, inputArgs, options] = executorSpy.mock.calls[0];
142
+ expect(target).toBe(statefulModel);
143
+ expect(eventName).toBe('beforeRender');
144
+ expect(inputArgs).toBeUndefined();
145
+ expect(options).toMatchObject({ useCache: true });
146
+
147
+ secondRender.unmount();
148
+ await waitFor(() => {
149
+ expect(onUnmountSpy).toHaveBeenCalledTimes(2);
150
+ });
151
+ });
89
152
  });
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
- import { render, cleanup, waitFor } from '@testing-library/react';
12
+ import { render, cleanup, fireEvent, waitFor } from '@testing-library/react';
13
13
  import { App, ConfigProvider } from 'antd';
14
14
  import { FlowEngine } from '../../flowEngine';
15
15
  import { FlowModel, ModelRenderMode } from '../../models/flowModel';
@@ -94,6 +94,16 @@ const clickDeleteFromLastDropdown = async () => {
94
94
  menu.onClick?.({ key: 'delete' });
95
95
  };
96
96
 
97
+ const getHost = (element: HTMLElement) => element.closest('[data-has-float-menu="true"]') as HTMLDivElement;
98
+
99
+ const hoverHostAndClickDelete = async (element: HTMLElement) => {
100
+ const host = getHost(element);
101
+ if (host) {
102
+ fireEvent.mouseEnter(host);
103
+ }
104
+ await clickDeleteFromLastDropdown();
105
+ };
106
+
97
107
  // ---------------- Tests ----------------
98
108
  describe('Delete problematic model via FlowSettings menu', () => {
99
109
  beforeEach(() => {
@@ -114,13 +124,13 @@ describe('Delete problematic model via FlowSettings menu', () => {
114
124
  }
115
125
 
116
126
  const engine = new FlowEngine();
117
- engine.flowSettings.forceEnable();
127
+ await engine.flowSettings.forceEnable();
118
128
  engine.registerModels({ BrokenModel });
119
129
  const model = engine.createModel({ use: 'BrokenModel', uid: 'broken-top-2' }) as BrokenModel;
120
130
  // satisfy FlowsFloatContextMenu styles
121
131
  model.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
122
132
 
123
- render(
133
+ const { findByTestId } = render(
124
134
  <ConfigProvider>
125
135
  <App>
126
136
  <FlowEngineProvider engine={engine}>
@@ -130,7 +140,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
130
140
  </ConfigProvider>,
131
141
  );
132
142
 
133
- await clickDeleteFromLastDropdown();
143
+ await hoverHostAndClickDelete(await findByTestId('result'));
134
144
  expect(engine.getModel(model.uid)).toBeUndefined();
135
145
  });
136
146
 
@@ -154,7 +164,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
154
164
  }
155
165
 
156
166
  const engine = new FlowEngine();
157
- engine.flowSettings.forceEnable();
167
+ await engine.flowSettings.forceEnable();
158
168
  engine.registerModels({ ParentModel, BrokenChild });
159
169
  const parent = engine.createModel({ use: 'ParentModel', uid: 'parent-3' }) as ParentModel;
160
170
  const child = engine.createModel({ use: 'BrokenChild', uid: 'child-3' }) as BrokenChild;
@@ -163,7 +173,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
163
173
  parent.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
164
174
  child.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
165
175
 
166
- render(
176
+ const { findByTestId } = render(
167
177
  <ConfigProvider>
168
178
  <App>
169
179
  <FlowEngineProvider engine={engine}>
@@ -173,7 +183,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
173
183
  </ConfigProvider>,
174
184
  );
175
185
 
176
- await clickDeleteFromLastDropdown();
186
+ await hoverHostAndClickDelete(await findByTestId('result'));
177
187
  expect(engine.getModel(child.uid)).toBeUndefined();
178
188
  const remain = (parent.subModels as any).items || [];
179
189
  expect(remain.find((m: FlowModel) => m.uid === child.uid)).toBeUndefined();
@@ -200,7 +210,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
200
210
  }
201
211
 
202
212
  const engine = new FlowEngine();
203
- engine.flowSettings.forceEnable();
213
+ await engine.flowSettings.forceEnable();
204
214
  engine.registerModels({ ParentModel, RenderFnChild });
205
215
  const parent = engine.createModel({ use: 'ParentModel', uid: 'parent-4' }) as ParentModel;
206
216
  const child = engine.createModel({ use: 'RenderFnChild', uid: 'cell-4' }) as RenderFnChild;
@@ -208,7 +218,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
208
218
  parent.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
209
219
  child.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
210
220
 
211
- render(
221
+ const { findByTestId } = render(
212
222
  <ConfigProvider>
213
223
  <App>
214
224
  <FlowEngineProvider engine={engine}>
@@ -218,7 +228,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
218
228
  </ConfigProvider>,
219
229
  );
220
230
 
221
- await clickDeleteFromLastDropdown();
231
+ await hoverHostAndClickDelete(await findByTestId('result'));
222
232
  expect(engine.getModel(child.uid)).toBeUndefined();
223
233
  const remain = (parent.subModels as any).cells || [];
224
234
  expect(remain.find((m: FlowModel) => m.uid === child.uid)).toBeUndefined();