@nocobase/flow-engine 2.1.0-beta.14 → 2.1.0-beta.16

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 (36) hide show
  1. package/lib/components/FlowModelRenderer.js +10 -6
  2. package/lib/components/MobilePopup.js +6 -5
  3. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +13 -5
  4. package/lib/components/subModel/AddSubModelButton.js +1 -1
  5. package/lib/components/subModel/utils.js +2 -2
  6. package/lib/flowEngine.d.ts +132 -1
  7. package/lib/flowEngine.js +360 -14
  8. package/lib/flowSettings.d.ts +14 -6
  9. package/lib/flowSettings.js +34 -6
  10. package/lib/lazy-helper.d.ts +14 -0
  11. package/lib/lazy-helper.js +71 -0
  12. package/lib/models/flowModel.d.ts +2 -1
  13. package/lib/models/flowModel.js +28 -9
  14. package/lib/types.d.ts +46 -0
  15. package/lib/utils/runjsTemplateCompat.js +1 -1
  16. package/package.json +4 -4
  17. package/src/__tests__/flow-engine.test.ts +166 -0
  18. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  19. package/src/__tests__/flowSettings.test.ts +94 -15
  20. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  21. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  22. package/src/components/FlowModelRenderer.tsx +9 -5
  23. package/src/components/MobilePopup.tsx +4 -2
  24. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  25. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +3 -3
  26. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +15 -4
  27. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +67 -5
  28. package/src/components/subModel/AddSubModelButton.tsx +1 -1
  29. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +93 -33
  30. package/src/components/subModel/utils.ts +1 -1
  31. package/src/flowEngine.ts +412 -10
  32. package/src/flowSettings.ts +40 -6
  33. package/src/lazy-helper.tsx +57 -0
  34. package/src/models/flowModel.tsx +31 -10
  35. package/src/types.ts +59 -0
  36. package/src/utils/runjsTemplateCompat.ts +1 -1
@@ -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)();
@@ -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
  });
@@ -127,6 +127,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
127
127
  settingsMenuLevel?: number;
128
128
  extraToolbarItems?: ToolbarItemConfig[];
129
129
  fallback?: React.ReactNode;
130
+ useCache?: boolean;
130
131
  }> = observer(
131
132
  ({
132
133
  model,
@@ -139,12 +140,12 @@ const FlowModelRendererWithAutoFlows: React.FC<{
139
140
  settingsMenuLevel,
140
141
  extraToolbarItems,
141
142
  fallback,
143
+ useCache,
142
144
  }) => {
143
145
  // hidden 占位由模型自身处理;无需在此注入
144
-
145
146
  const { loading: pending, error: autoFlowsError } = useApplyAutoFlows(model, inputArgs, {
146
147
  throwOnError: false,
147
- useCache: model.context.useCache,
148
+ useCache,
148
149
  });
149
150
  // 将错误下沉到 model 实例上,供内容层读取(类型安全的 WeakMap 存储)
150
151
  setAutoFlowError(model, autoFlowsError || null);
@@ -348,13 +349,15 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
348
349
  extraToolbarItems,
349
350
  useCache,
350
351
  }) => {
352
+ const resolvedUseCache = typeof useCache === 'boolean' ? useCache : model?.context?.useCache;
353
+
351
354
  useEffect(() => {
352
- if (model?.context) {
355
+ if (model?.context && typeof resolvedUseCache !== 'undefined') {
353
356
  model.context.defineProperty('useCache', {
354
- value: typeof useCache === 'boolean' ? useCache : model.context.useCache,
357
+ value: resolvedUseCache,
355
358
  });
356
359
  }
357
- }, [model?.context, useCache]);
360
+ }, [model?.context, resolvedUseCache]);
358
361
 
359
362
  if (!model || typeof model.render !== 'function') {
360
363
  // 可以选择渲染 null 或者一个错误/提示信息
@@ -375,6 +378,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
375
378
  settingsMenuLevel={settingsMenuLevel}
376
379
  extraToolbarItems={extraToolbarItems}
377
380
  fallback={fallback}
381
+ useCache={resolvedUseCache}
378
382
  />
379
383
  );
380
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
  });
@@ -124,7 +124,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
124
124
  }
125
125
 
126
126
  const engine = new FlowEngine();
127
- engine.flowSettings.forceEnable();
127
+ await engine.flowSettings.forceEnable();
128
128
  engine.registerModels({ BrokenModel });
129
129
  const model = engine.createModel({ use: 'BrokenModel', uid: 'broken-top-2' }) as BrokenModel;
130
130
  // satisfy FlowsFloatContextMenu styles
@@ -164,7 +164,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
164
164
  }
165
165
 
166
166
  const engine = new FlowEngine();
167
- engine.flowSettings.forceEnable();
167
+ await engine.flowSettings.forceEnable();
168
168
  engine.registerModels({ ParentModel, BrokenChild });
169
169
  const parent = engine.createModel({ use: 'ParentModel', uid: 'parent-3' }) as ParentModel;
170
170
  const child = engine.createModel({ use: 'BrokenChild', uid: 'child-3' }) as BrokenChild;
@@ -210,7 +210,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
210
210
  }
211
211
 
212
212
  const engine = new FlowEngine();
213
- engine.flowSettings.forceEnable();
213
+ await engine.flowSettings.forceEnable();
214
214
  engine.registerModels({ ParentModel, RenderFnChild });
215
215
  const parent = engine.createModel({ use: 'ParentModel', uid: 'parent-4' }) as ParentModel;
216
216
  const child = engine.createModel({ use: 'RenderFnChild', uid: 'cell-4' }) as RenderFnChild;
@@ -70,6 +70,15 @@ interface BaseFloatContextMenuProps {
70
70
  toolbarPosition?: ToolbarPosition;
71
71
  }
72
72
 
73
+ const getFloatMenuInstanceId = (model?: FlowModel | null) => {
74
+ if (!model) {
75
+ return '';
76
+ }
77
+
78
+ const forkId = (model as any)?.isFork ? (model as any)?.forkId : undefined;
79
+ return forkId == null || forkId === '' ? String(model.uid || '') : `${String(model.uid || '')}::${String(forkId)}`;
80
+ };
81
+
73
82
  const hostContainerStyles = css`
74
83
  position: relative;
75
84
 
@@ -279,6 +288,7 @@ const detectButtonInDOM = (container: HTMLElement): boolean => {
279
288
  // 渲染工具栏项目,并让设置菜单与工具栏共享同一个 popup 容器。
280
289
  const renderToolbarItems = (
281
290
  model: FlowModel,
291
+ modelInstanceId: string,
282
292
  showDeleteButton: boolean,
283
293
  showCopyUidButton: boolean,
284
294
  flowEngine: FlowEngine,
@@ -304,7 +314,7 @@ const renderToolbarItems = (
304
314
  <ItemComponent
305
315
  key={itemConfig.key}
306
316
  model={model}
307
- id={model.uid}
317
+ id={modelInstanceId}
308
318
  showDeleteButton={showDeleteButton}
309
319
  showCopyUidButton={showCopyUidButton}
310
320
  menuLevels={settingsMenuLevel}
@@ -517,7 +527,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
517
527
  updatePortalRect: () => {},
518
528
  schedulePortalRectUpdate: () => {},
519
529
  });
520
- const modelUid = model?.uid || '';
530
+ const modelUid = getFloatMenuInstanceId(model);
521
531
  const flowEngine = useFlowEngine();
522
532
  const updatePortalRectProxy = useCallback(() => {
523
533
  portalActionsRef.current.updatePortalRect();
@@ -559,6 +569,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
559
569
  model
560
570
  ? renderToolbarItems(
561
571
  model,
572
+ modelUid,
562
573
  showDeleteButton,
563
574
  showCopyUidButton,
564
575
  flowEngine,
@@ -629,7 +640,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
629
640
  ref={toolbarContainerRef}
630
641
  className={`nb-toolbar-container ${toolbarContainerClassName}`}
631
642
  style={toolbarContainerStyle}
632
- data-model-uid={model.uid}
643
+ data-model-uid={modelUid}
633
644
  >
634
645
  {showTitle && (model.title || model.extraTitle) && (
635
646
  <div className="nb-toolbar-container-title">
@@ -668,7 +679,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
668
679
  className={`${hostContainerStyles} ${hasButton ? 'has-button-child' : ''} ${className || ''}`}
669
680
  style={containerStyle}
670
681
  data-has-float-menu="true"
671
- data-float-menu-model-uid={model.uid}
682
+ data-float-menu-model-uid={modelUid}
672
683
  onMouseMove={handleChildHover}
673
684
  onMouseEnter={handleHostMouseEnter}
674
685
  onMouseLeave={handleHostMouseLeave}
@@ -192,7 +192,7 @@ describe('FlowsFloatContextMenu', () => {
192
192
 
193
193
  it('defaults to portal into app container and keeps toolbar visible while moving from host to toolbar', async () => {
194
194
  const engine = new FlowEngine();
195
- engine.flowSettings.forceEnable();
195
+ await engine.flowSettings.forceEnable();
196
196
  const model = createModel(engine, 'portal-model');
197
197
  const appContainer = createAppContainer();
198
198
  appContainer.scrollTop = 8;
@@ -254,7 +254,7 @@ describe('FlowsFloatContextMenu', () => {
254
254
 
255
255
  it('renders through FlowModelRenderer with app-container portal and keeps toolbar pinned while dropdown is open', async () => {
256
256
  const engine = new FlowEngine();
257
- engine.flowSettings.forceEnable();
257
+ await engine.flowSettings.forceEnable();
258
258
  const model = createModel(engine, 'renderer-model');
259
259
  const appContainer = createAppContainer();
260
260
  mockRect(appContainer, { top: 40, left: 60, width: 1200, height: 800 });
@@ -316,7 +316,7 @@ describe('FlowsFloatContextMenu', () => {
316
316
 
317
317
  it('portals field toolbar to the nearest popup root and treats inset values as rect adjustments', async () => {
318
318
  const engine = new FlowEngine();
319
- engine.flowSettings.forceEnable();
319
+ await engine.flowSettings.forceEnable();
320
320
  const model = createModel(engine, 'field-model');
321
321
  model.render = vi.fn().mockReturnValue(<input data-testid="field-input" />);
322
322
  const insetModel = createModel(engine, 'field-inset-model');
@@ -400,7 +400,7 @@ describe('FlowsFloatContextMenu', () => {
400
400
 
401
401
  it('hides parent toolbar when hovering a nested child host', async () => {
402
402
  const engine = new FlowEngine();
403
- engine.flowSettings.forceEnable();
403
+ await engine.flowSettings.forceEnable();
404
404
  const parentModel = createModel(engine, 'parent-model');
405
405
  const childModel = createModel(engine, 'child-model');
406
406
  const appContainer = createAppContainer();
@@ -470,7 +470,7 @@ describe('FlowsFloatContextMenu', () => {
470
470
 
471
471
  it('restores parent toolbar after leaving a child toolbar back into the parent block', async () => {
472
472
  const engine = new FlowEngine();
473
- engine.flowSettings.forceEnable();
473
+ await engine.flowSettings.forceEnable();
474
474
  const parentModel = createModel(engine, 'parent-restore-model');
475
475
  const childModel = createModel(engine, 'child-restore-model');
476
476
  const appContainer = createAppContainer();
@@ -544,4 +544,66 @@ describe('FlowsFloatContextMenu', () => {
544
544
  expect(parentOverlayAfterRestore?.className).toContain('nb-toolbar-visible');
545
545
  });
546
546
  });
547
+
548
+ it('treats forked models as distinct float menu instances even when they share the same uid', async () => {
549
+ const engine = new FlowEngine();
550
+ await engine.flowSettings.forceEnable();
551
+ const masterModel = new FlowModel({ uid: 'forked-model', flowEngine: engine });
552
+ masterModel.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
553
+ masterModel.render = vi.fn(function (this: any) {
554
+ return <div data-testid={`content-${String(this.forkId || this.uid)}`}>{String(this.forkId || this.uid)}</div>;
555
+ });
556
+
557
+ const firstFork = masterModel.createFork({}, 'card-1') as FlowModel & { forkId?: string };
558
+ const secondFork = masterModel.createFork({}, 'card-2') as FlowModel & { forkId?: string };
559
+ const firstInstanceId = `forked-model::${String((firstFork as any).forkId)}`;
560
+ const secondInstanceId = `forked-model::${String((secondFork as any).forkId)}`;
561
+ const appContainer = createAppContainer();
562
+ mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
563
+
564
+ const { getByTestId } = renderWithProviders(
565
+ engine,
566
+ <>
567
+ <FlowsFloatContextMenu model={firstFork}>
568
+ <div data-testid="fork-host-1">first</div>
569
+ </FlowsFloatContextMenu>
570
+ <FlowsFloatContextMenu model={secondFork}>
571
+ <div data-testid="fork-host-2">second</div>
572
+ </FlowsFloatContextMenu>
573
+ </>,
574
+ { container: appContainer },
575
+ );
576
+
577
+ const firstHost = getHost(getByTestId('fork-host-1'));
578
+ const secondHost = getHost(getByTestId('fork-host-2'));
579
+ mockRect(firstHost, { top: 20, left: 20, width: 180, height: 72 });
580
+ mockRect(secondHost, { top: 120, left: 20, width: 180, height: 72 });
581
+
582
+ fireEvent.mouseEnter(firstHost);
583
+
584
+ const firstOverlay = await waitFor(() => {
585
+ const nextOverlay = queryOverlay(appContainer, firstInstanceId);
586
+ expect(nextOverlay).toBeTruthy();
587
+ return nextOverlay as HTMLDivElement;
588
+ });
589
+
590
+ await waitFor(() => {
591
+ expect(within(firstOverlay).getByLabelText('flows-settings')).toBeTruthy();
592
+ });
593
+
594
+ fireEvent.mouseEnter(secondHost);
595
+
596
+ const secondOverlay = await waitFor(() => {
597
+ const nextOverlay = queryOverlay(appContainer, secondInstanceId);
598
+ expect(nextOverlay).toBeTruthy();
599
+ return nextOverlay as HTMLDivElement;
600
+ });
601
+
602
+ await waitFor(() => {
603
+ expect(within(secondOverlay).getByLabelText('flows-settings')).toBeTruthy();
604
+ });
605
+
606
+ expect(firstOverlay.getAttribute('data-model-uid')).toBe(firstInstanceId);
607
+ expect(secondOverlay.getAttribute('data-model-uid')).toBe(secondInstanceId);
608
+ });
547
609
  });
@@ -610,7 +610,7 @@ const AddSubModelButtonCore = function AddSubModelButton({
610
610
  let addedModel: FlowModel | undefined;
611
611
 
612
612
  try {
613
- addedModel = model.flowEngine.createModel({
613
+ addedModel = await model.flowEngine.createModelAsync({
614
614
  ..._.cloneDeep(createOpts),
615
615
  parentId: model.uid,
616
616
  subKey: subModelKey,