@nocobase/flow-engine 2.1.0-beta.9 → 2.1.0

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 (215) hide show
  1. package/lib/FlowContextProvider.d.ts +5 -1
  2. package/lib/FlowContextProvider.js +9 -2
  3. package/lib/components/FieldModelRenderer.js +2 -2
  4. package/lib/components/FlowModelRenderer.d.ts +3 -1
  5. package/lib/components/FlowModelRenderer.js +12 -6
  6. package/lib/components/FormItem.d.ts +6 -0
  7. package/lib/components/FormItem.js +11 -3
  8. package/lib/components/MobilePopup.js +6 -5
  9. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  10. package/lib/components/dnd/gridDragPlanner.js +607 -19
  11. package/lib/components/dnd/index.d.ts +31 -2
  12. package/lib/components/dnd/index.js +244 -23
  13. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  14. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  15. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  16. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +152 -42
  17. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  18. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  19. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  20. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  21. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  22. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  23. package/lib/components/subModel/AddSubModelButton.js +12 -1
  24. package/lib/components/subModel/LazyDropdown.js +301 -52
  25. package/lib/components/subModel/index.d.ts +1 -0
  26. package/lib/components/subModel/index.js +19 -0
  27. package/lib/components/subModel/utils.d.ts +2 -1
  28. package/lib/components/subModel/utils.js +15 -5
  29. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  30. package/lib/components/variables/VariableHybridInput.js +499 -0
  31. package/lib/components/variables/index.d.ts +2 -0
  32. package/lib/components/variables/index.js +3 -0
  33. package/lib/data-source/index.d.ts +84 -0
  34. package/lib/data-source/index.js +269 -7
  35. package/lib/executor/FlowExecutor.js +6 -3
  36. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  37. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  38. package/lib/flow-registry/index.d.ts +1 -0
  39. package/lib/flow-registry/index.js +3 -1
  40. package/lib/flowContext.d.ts +9 -1
  41. package/lib/flowContext.js +77 -6
  42. package/lib/flowEngine.d.ts +136 -4
  43. package/lib/flowEngine.js +429 -51
  44. package/lib/flowI18n.js +2 -1
  45. package/lib/flowSettings.d.ts +14 -6
  46. package/lib/flowSettings.js +34 -6
  47. package/lib/index.d.ts +2 -0
  48. package/lib/index.js +7 -0
  49. package/lib/lazy-helper.d.ts +14 -0
  50. package/lib/lazy-helper.js +71 -0
  51. package/lib/locale/en-US.json +1 -0
  52. package/lib/locale/index.d.ts +2 -0
  53. package/lib/locale/zh-CN.json +1 -0
  54. package/lib/models/DisplayItemModel.d.ts +1 -1
  55. package/lib/models/EditableItemModel.d.ts +1 -1
  56. package/lib/models/FilterableItemModel.d.ts +1 -1
  57. package/lib/models/flowModel.d.ts +13 -10
  58. package/lib/models/flowModel.js +126 -34
  59. package/lib/provider.js +38 -23
  60. package/lib/reactive/observer.js +46 -16
  61. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  62. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  63. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  64. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  65. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  66. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  67. package/lib/runjs-context/contexts/base.js +464 -29
  68. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  69. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  70. package/lib/runjs-context/setup.js +1 -0
  71. package/lib/runjs-context/snippets/index.js +13 -2
  72. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  74. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  75. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  76. package/lib/types.d.ts +50 -2
  77. package/lib/types.js +1 -0
  78. package/lib/utils/createCollectionContextMeta.js +6 -2
  79. package/lib/utils/index.d.ts +3 -2
  80. package/lib/utils/index.js +7 -0
  81. package/lib/utils/loadedPageCache.d.ts +24 -0
  82. package/lib/utils/loadedPageCache.js +139 -0
  83. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  84. package/lib/utils/parsePathnameToViewParams.js +28 -4
  85. package/lib/utils/randomId.d.ts +39 -0
  86. package/lib/utils/randomId.js +45 -0
  87. package/lib/utils/runjsTemplateCompat.js +1 -1
  88. package/lib/utils/runjsValue.js +41 -11
  89. package/lib/utils/schema-utils.d.ts +7 -1
  90. package/lib/utils/schema-utils.js +19 -0
  91. package/lib/views/FlowView.d.ts +7 -1
  92. package/lib/views/FlowView.js +11 -1
  93. package/lib/views/PageComponent.js +8 -6
  94. package/lib/views/ViewNavigation.d.ts +12 -2
  95. package/lib/views/ViewNavigation.js +28 -9
  96. package/lib/views/createViewMeta.js +114 -50
  97. package/lib/views/inheritLayoutContext.d.ts +10 -0
  98. package/lib/views/inheritLayoutContext.js +50 -0
  99. package/lib/views/runViewBeforeClose.d.ts +10 -0
  100. package/lib/views/runViewBeforeClose.js +45 -0
  101. package/lib/views/useDialog.d.ts +2 -1
  102. package/lib/views/useDialog.js +12 -3
  103. package/lib/views/useDrawer.d.ts +2 -1
  104. package/lib/views/useDrawer.js +12 -3
  105. package/lib/views/usePage.d.ts +5 -11
  106. package/lib/views/usePage.js +304 -144
  107. package/package.json +5 -4
  108. package/src/FlowContextProvider.tsx +9 -1
  109. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  110. package/src/__tests__/flow-engine.test.ts +166 -0
  111. package/src/__tests__/flowContext.test.ts +105 -1
  112. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  113. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  114. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  115. package/src/__tests__/flowSettings.test.ts +94 -15
  116. package/src/__tests__/objectVariable.test.ts +24 -0
  117. package/src/__tests__/provider.test.tsx +24 -2
  118. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  119. package/src/__tests__/runjsContext.test.ts +21 -0
  120. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  121. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  122. package/src/__tests__/runjsLocales.test.ts +6 -5
  123. package/src/__tests__/runjsSnippets.test.ts +21 -0
  124. package/src/__tests__/viewScopedFlowEngine.test.ts +136 -3
  125. package/src/components/FieldModelRenderer.tsx +2 -1
  126. package/src/components/FlowModelRenderer.tsx +18 -6
  127. package/src/components/FormItem.tsx +7 -1
  128. package/src/components/MobilePopup.tsx +4 -2
  129. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  130. package/src/components/__tests__/FormItem.test.tsx +25 -0
  131. package/src/components/__tests__/dnd.test.ts +44 -0
  132. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  133. package/src/components/__tests__/gridDragPlanner.test.ts +472 -5
  134. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  135. package/src/components/dnd/gridDragPlanner.ts +750 -17
  136. package/src/components/dnd/index.tsx +305 -28
  137. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  138. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +178 -48
  139. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +344 -8
  141. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  142. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  143. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  144. package/src/components/subModel/AddSubModelButton.tsx +16 -2
  145. package/src/components/subModel/LazyDropdown.tsx +341 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +524 -38
  147. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  148. package/src/components/subModel/index.ts +1 -0
  149. package/src/components/subModel/utils.ts +13 -2
  150. package/src/components/variables/VariableHybridInput.tsx +531 -0
  151. package/src/components/variables/index.ts +2 -0
  152. package/src/data-source/__tests__/collection.test.ts +41 -2
  153. package/src/data-source/__tests__/index.test.ts +69 -2
  154. package/src/data-source/index.ts +332 -8
  155. package/src/executor/FlowExecutor.ts +6 -3
  156. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  157. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  158. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  159. package/src/flow-registry/index.ts +1 -0
  160. package/src/flowContext.ts +85 -6
  161. package/src/flowEngine.ts +484 -45
  162. package/src/flowI18n.ts +2 -1
  163. package/src/flowSettings.ts +40 -6
  164. package/src/index.ts +2 -0
  165. package/src/lazy-helper.tsx +57 -0
  166. package/src/locale/en-US.json +1 -0
  167. package/src/locale/zh-CN.json +1 -0
  168. package/src/models/DisplayItemModel.tsx +1 -1
  169. package/src/models/EditableItemModel.tsx +1 -1
  170. package/src/models/FilterableItemModel.tsx +1 -1
  171. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  172. package/src/models/__tests__/flowModel.test.ts +65 -37
  173. package/src/models/flowModel.tsx +184 -65
  174. package/src/provider.tsx +41 -25
  175. package/src/reactive/__tests__/observer.test.tsx +82 -0
  176. package/src/reactive/observer.tsx +87 -25
  177. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  178. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  179. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  180. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  181. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  182. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  183. package/src/runjs-context/contexts/base.ts +467 -31
  184. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  185. package/src/runjs-context/setup.ts +1 -0
  186. package/src/runjs-context/snippets/index.ts +12 -1
  187. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  188. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  189. package/src/types.ts +62 -0
  190. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  191. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  192. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  193. package/src/utils/__tests__/utils.test.ts +62 -0
  194. package/src/utils/createCollectionContextMeta.ts +6 -2
  195. package/src/utils/index.ts +5 -1
  196. package/src/utils/loadedPageCache.ts +147 -0
  197. package/src/utils/parsePathnameToViewParams.ts +45 -5
  198. package/src/utils/randomId.ts +48 -0
  199. package/src/utils/runjsTemplateCompat.ts +1 -1
  200. package/src/utils/runjsValue.ts +50 -11
  201. package/src/utils/schema-utils.ts +30 -1
  202. package/src/views/FlowView.tsx +22 -2
  203. package/src/views/PageComponent.tsx +7 -4
  204. package/src/views/ViewNavigation.ts +46 -9
  205. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  206. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  207. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  208. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  209. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +12 -12
  210. package/src/views/createViewMeta.ts +106 -34
  211. package/src/views/inheritLayoutContext.ts +26 -0
  212. package/src/views/runViewBeforeClose.ts +19 -0
  213. package/src/views/useDialog.tsx +13 -3
  214. package/src/views/useDrawer.tsx +13 -3
  215. package/src/views/usePage.tsx +367 -180
package/src/flowEngine.ts CHANGED
@@ -20,11 +20,16 @@ import { APIResource, FlowResource, MultiRecordResource, SingleRecordResource, S
20
20
  import { Emitter } from './emitter';
21
21
  import ModelOperationScheduler from './scheduler/ModelOperationScheduler';
22
22
  import type { ScheduleOptions, ScheduledCancel } from './scheduler/ModelOperationScheduler';
23
+ import { createLoadedPageCache } from './utils/loadedPageCache';
23
24
  import type {
24
25
  ActionDefinition,
25
26
  ApplyFlowCacheEntry,
26
27
  CreateModelOptions,
28
+ EnsureBatchResult,
27
29
  EventDefinition,
30
+ FlowModelLoaderEntry,
31
+ FlowModelLoaderInputMap,
32
+ FlowModelLoaderResult,
28
33
  FlowModelOptions,
29
34
  IFlowModelRepository,
30
35
  ModelConstructor,
@@ -35,6 +40,8 @@ import type {
35
40
  } from './types';
36
41
  import { isInheritedFrom } from './utils';
37
42
 
43
+ const getFlowEngineLoggerLevel = () => (process.env.NODE_ENV === 'production' ? 'warn' : 'trace');
44
+
38
45
  /**
39
46
  * FlowEngine is the core class of the flow engine, responsible for managing flow models, actions, model repository, and more.
40
47
  * It provides capabilities for registering, creating, finding, persisting, replacing, and moving models.
@@ -75,6 +82,32 @@ export class FlowEngine {
75
82
  */
76
83
  private _modelClasses: Map<string, ModelConstructor> = observable.shallow(new Map());
77
84
 
85
+ /**
86
+ * Registered model entries.
87
+ * Key is the model class name, value is the model loader entry.
88
+ * @private
89
+ */
90
+ private _modelLoaders: Map<string, FlowModelLoaderEntry> = new Map();
91
+
92
+ /**
93
+ * In-flight model loading promises.
94
+ * Key is the model class name, value is the loading promise.
95
+ * @private
96
+ */
97
+ private _loadingModelPromises: Map<string, Promise<ModelConstructor | null>> = new Map();
98
+
99
+ /**
100
+ * Whether model-loader preload has completed in this session.
101
+ * @private
102
+ */
103
+ private _modelLoadersPreloaded = false;
104
+
105
+ /**
106
+ * In-flight model-loader preload promise.
107
+ * @private
108
+ */
109
+ private _modelLoadersPreloadPromise?: Promise<EnsureBatchResult>;
110
+
78
111
  /**
79
112
  * Created model instances.
80
113
  * Key is the model instance UID, value is the model instance object.
@@ -103,6 +136,8 @@ export class FlowEngine {
103
136
  */
104
137
  private _savingModels = new Map<string, Promise<any>>();
105
138
 
139
+ private _loadedPageCache = createLoadedPageCache();
140
+
106
141
  /**
107
142
  * Flow engine context object.
108
143
  * @private
@@ -183,7 +218,7 @@ export class FlowEngine {
183
218
  MultiRecordResource,
184
219
  });
185
220
  this.logger = pino({
186
- level: 'trace',
221
+ level: getFlowEngineLoggerLevel(),
187
222
  browser: {
188
223
  write: {
189
224
  fatal: (o) => console.trace(o),
@@ -424,6 +459,13 @@ export class FlowEngine {
424
459
  * @private
425
460
  */
426
461
  #registerModel(name: string, modelClass: ModelConstructor): void {
462
+ return this._registerModel(name, modelClass);
463
+ }
464
+
465
+ /**
466
+ * for proxy instance, the #registerModel can't be called.
467
+ */
468
+ private _registerModel(name: string, modelClass: ModelConstructor): void {
427
469
  if (this._modelClasses.has(name)) {
428
470
  console.warn(`FlowEngine: Model class with name '${name}' is already registered and will be overwritten.`);
429
471
  }
@@ -444,6 +486,306 @@ export class FlowEngine {
444
486
  }
445
487
  }
446
488
 
489
+ /**
490
+ * Register multiple model loader entries.
491
+ * The `extends` field declares parent class(es) for async subclass discovery via `getSubclassesOfAsync`.
492
+ * It accepts `string | ModelConstructor | (string | ModelConstructor)[]` and is normalized to `string[]` internally.
493
+ * @param {FlowModelLoaderInputMap} loaders Model loader input map
494
+ * @returns {void}
495
+ * @example
496
+ * flowEngine.registerModelLoaders({
497
+ * DemoModel: {
498
+ * extends: 'BaseModel',
499
+ * loader: () => import('./models/DemoModel'),
500
+ * },
501
+ * });
502
+ */
503
+ public registerModelLoaders(loaders: FlowModelLoaderInputMap): void {
504
+ let changed = false;
505
+ for (const [name, input] of Object.entries(loaders)) {
506
+ if (this._modelLoaders.has(name)) {
507
+ console.warn(`FlowEngine: Model loader with name '${name}' is already registered and will be overwritten.`);
508
+ }
509
+ const entry: FlowModelLoaderEntry = {
510
+ loader: input.loader,
511
+ };
512
+ if (input.extends != null) {
513
+ const raw = Array.isArray(input.extends) ? input.extends : [input.extends];
514
+ entry.extends = raw.map((item) => (typeof item === 'string' ? item : item.name));
515
+ }
516
+ this._modelLoaders.set(name, entry);
517
+ changed = true;
518
+ }
519
+ if (changed) {
520
+ this._modelLoadersPreloaded = false;
521
+ this._modelLoadersPreloadPromise = undefined;
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Get a registered model class (constructor) asynchronously.
527
+ * This will first ensure the model loader entry is resolved.
528
+ * @param {string} name Model class name
529
+ * @returns {Promise<ModelConstructor | undefined>} Model constructor, or undefined if not found
530
+ */
531
+ public async getModelClassAsync(name: string): Promise<ModelConstructor | undefined> {
532
+ await this.ensureModel(name);
533
+ return this.getModelClass(name);
534
+ }
535
+
536
+ /**
537
+ * Get all registered model classes asynchronously.
538
+ * This will first ensure all registered model loader entries are resolved.
539
+ * @returns {Promise<Map<string, ModelConstructor>>} Model class map
540
+ */
541
+ public async getModelClassesAsync(): Promise<Map<string, ModelConstructor>> {
542
+ await this.ensureModels(Array.from(this._modelLoaders.keys()));
543
+ return this.getModelClasses();
544
+ }
545
+
546
+ /**
547
+ * Create and register a model instance asynchronously.
548
+ * This will first ensure all string-based model references in the model tree are resolved.
549
+ * @template T FlowModel subclass type, defaults to FlowModel.
550
+ * @param {CreateModelOptions} options Model creation options
551
+ * @returns {Promise<T>} Created model instance
552
+ */
553
+ public async createModelAsync<T extends FlowModel = FlowModel>(
554
+ options: CreateModelOptions,
555
+ extra?: { delegateToParent?: boolean; delegate?: FlowContext },
556
+ ): Promise<T> {
557
+ await this.resolveModelTree(options);
558
+ return this.createModel<T>(options, extra);
559
+ }
560
+
561
+ /**
562
+ * Normalize a loader result into a model constructor.
563
+ * @param {string} name Model class name
564
+ * @param {FlowModelLoaderResult} loaded Loader result
565
+ * @returns {ModelConstructor | null} Normalized model constructor
566
+ * @private
567
+ */
568
+ private normalizeModelLoaderResult(name: string, loaded: FlowModelLoaderResult): ModelConstructor | null {
569
+ if (typeof loaded === 'function') {
570
+ return loaded as ModelConstructor;
571
+ }
572
+ if (loaded && typeof loaded === 'object') {
573
+ const defaultExport = loaded.default;
574
+ if (typeof defaultExport === 'function') {
575
+ return defaultExport as ModelConstructor;
576
+ }
577
+ const namedExport = loaded[name];
578
+ if (typeof namedExport === 'function') {
579
+ return namedExport as ModelConstructor;
580
+ }
581
+ }
582
+ console.warn(`FlowEngine: model loader for '${name}' did not resolve to a valid model constructor.`);
583
+ return null;
584
+ }
585
+
586
+ /**
587
+ * Collect string-based model names from a model tree.
588
+ * @param {unknown} data Model tree data
589
+ * @param {Set<string>} names Model name set
590
+ * @private
591
+ */
592
+ private collectModelNamesFromTree(data: unknown, names: Set<string>): void {
593
+ if (!data || typeof data !== 'object') {
594
+ return;
595
+ }
596
+ if (Array.isArray(data)) {
597
+ data.forEach((item) => this.collectModelNamesFromTree(item, names));
598
+ return;
599
+ }
600
+
601
+ const tree = data as Record<string, any>;
602
+ if (typeof tree.use === 'string') {
603
+ names.add(tree.use);
604
+ }
605
+
606
+ const subModels = tree.subModels;
607
+ if (!subModels || typeof subModels !== 'object') {
608
+ return;
609
+ }
610
+
611
+ Object.values(subModels).forEach((value) => {
612
+ this.collectModelNamesFromTree(value, names);
613
+ });
614
+ }
615
+
616
+ /**
617
+ * Collect additional model names from object-form meta.createModelOptions defaults.
618
+ * @param {ModelConstructor} modelClass Model class constructor
619
+ * @param {Set<string>} names Model name set
620
+ * @private
621
+ */
622
+ private collectModelNamesFromMetaDefaults(modelClass: ModelConstructor, names: Set<string>): void {
623
+ const metaCreate = (modelClass as typeof FlowModel).meta?.createModelOptions;
624
+ if (metaCreate && typeof metaCreate === 'object') {
625
+ this.collectModelNamesFromTree(metaCreate, names);
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Ensure a single model class is available.
631
+ * @param {string} name Model class name
632
+ * @returns {Promise<ModelConstructor | null>} Model constructor or null when resolution fails
633
+ * @private
634
+ */
635
+ private async ensureModel(name: string): Promise<ModelConstructor | null> {
636
+ const existing = this._modelClasses.get(name);
637
+ if (existing) {
638
+ return existing;
639
+ }
640
+
641
+ const inflight = this._loadingModelPromises.get(name);
642
+ if (inflight) {
643
+ return inflight;
644
+ }
645
+
646
+ const entry = this._modelLoaders.get(name);
647
+ if (!entry) {
648
+ console.warn(`FlowEngine: Model entry '${name}' not found. Falling back to ErrorFlowModel when needed.`);
649
+ return null;
650
+ }
651
+
652
+ const promise = (async () => {
653
+ try {
654
+ const loaded = await entry.loader();
655
+ const modelClass = this.normalizeModelLoaderResult(name, loaded);
656
+ if (!modelClass) {
657
+ return null;
658
+ }
659
+ // 这里拿到的 this 是 Proxy(FlowEngine) 而不是原始的 FlowEngine,无法直接调用 #registerModel
660
+ this._registerModel(name, modelClass);
661
+ return modelClass;
662
+ } catch (error) {
663
+ console.warn(`FlowEngine: Failed to load model '${name}'. Falling back to ErrorFlowModel when needed.`, error);
664
+ return null;
665
+ } finally {
666
+ this._loadingModelPromises.delete(name);
667
+ }
668
+ })();
669
+
670
+ this._loadingModelPromises.set(name, promise);
671
+ return promise;
672
+ }
673
+
674
+ /**
675
+ * Ensure multiple model classes are available.
676
+ * @param {string[]} names Model class names
677
+ * @returns {Promise<EnsureBatchResult>} Batch ensure result
678
+ * @private
679
+ */
680
+ private async ensureModels(names: string[]): Promise<EnsureBatchResult> {
681
+ const requested = Array.from(new Set(names.filter((name): name is string => !!name)));
682
+ const loaded: string[] = [];
683
+ const failed: EnsureBatchResult['failed'] = [];
684
+
685
+ const results = await Promise.all(
686
+ requested.map(async (name) => {
687
+ const modelClass = await this.ensureModel(name);
688
+ return { name, modelClass };
689
+ }),
690
+ );
691
+
692
+ results.forEach(({ name, modelClass }) => {
693
+ if (modelClass) {
694
+ loaded.push(name);
695
+ } else {
696
+ failed.push({ name });
697
+ }
698
+ });
699
+
700
+ return { requested, loaded, failed };
701
+ }
702
+
703
+ /**
704
+ * Resolve all unresolved string-based model references in a model tree before synchronous creation begins.
705
+ *
706
+ * Use this when you already have a model tree object, such as repository-returned data or resolved
707
+ * `createModelOptions`, and you need to ensure every string `use` in that tree has been loaded and
708
+ * registered into `_modelClasses` before calling `createModel()`.
709
+ *
710
+ * @param {unknown} data Model tree data
711
+ * @returns {Promise<EnsureBatchResult>} Batch ensure result
712
+ */
713
+ public async resolveModelTree(data: unknown): Promise<EnsureBatchResult> {
714
+ const requested = new Set<string>();
715
+ const loaded = new Set<string>();
716
+ const failed = new Map<string, { name: string; error?: unknown }>();
717
+ const processed = new Set<string>();
718
+ const pending = new Set<string>();
719
+
720
+ this.collectModelNamesFromTree(data, pending);
721
+
722
+ while (pending.size > 0) {
723
+ const batch = Array.from(pending).filter((name) => !processed.has(name));
724
+ pending.clear();
725
+ if (batch.length === 0) {
726
+ break;
727
+ }
728
+
729
+ batch.forEach((name) => requested.add(name));
730
+ const result = await this.ensureModels(batch);
731
+
732
+ result.loaded.forEach((name) => {
733
+ processed.add(name);
734
+ loaded.add(name);
735
+ const modelClass = this.getModelClass(name);
736
+ if (modelClass) {
737
+ const discovered = new Set<string>();
738
+ this.collectModelNamesFromMetaDefaults(modelClass, discovered);
739
+ discovered.forEach((discoveredName) => {
740
+ if (!processed.has(discoveredName)) {
741
+ pending.add(discoveredName);
742
+ }
743
+ });
744
+ }
745
+ });
746
+
747
+ result.failed.forEach((item) => {
748
+ processed.add(item.name);
749
+ failed.set(item.name, item);
750
+ });
751
+ }
752
+
753
+ return {
754
+ requested: Array.from(requested),
755
+ loaded: Array.from(loaded),
756
+ failed: Array.from(failed.values()),
757
+ };
758
+ }
759
+
760
+ /**
761
+ * Preload all currently registered unresolved model loaders.
762
+ *
763
+ * This method is intended for flow-settings/discovery style entry points that need registered model
764
+ * classes to exist before UI is rendered, without requiring callers to know which specific models
765
+ * will be touched next.
766
+ *
767
+ * @returns {Promise<EnsureBatchResult>} Batch ensure result
768
+ */
769
+ public async preloadModelLoaders(): Promise<EnsureBatchResult> {
770
+ const unresolved = Array.from(this._modelLoaders.keys()).filter((name) => !this._modelClasses.has(name));
771
+ if (unresolved.length === 0) {
772
+ this._modelLoadersPreloaded = true;
773
+ return { requested: [], loaded: [], failed: [] };
774
+ }
775
+ if (this._modelLoadersPreloadPromise) {
776
+ return this._modelLoadersPreloadPromise;
777
+ }
778
+
779
+ this._modelLoadersPreloadPromise = (async () => {
780
+ const result = await this.ensureModels(unresolved);
781
+ this._modelLoadersPreloaded = result.failed.length === 0;
782
+ this._modelLoadersPreloadPromise = undefined;
783
+ return result;
784
+ })();
785
+
786
+ return this._modelLoadersPreloadPromise;
787
+ }
788
+
447
789
  registerResources(resources: Record<string, any>) {
448
790
  for (const [name, resourceClass] of Object.entries(resources)) {
449
791
  this._resources.set(name, resourceClass);
@@ -518,6 +860,70 @@ export class FlowEngine {
518
860
  return result;
519
861
  }
520
862
 
863
+ /**
864
+ * Asynchronously get all subclasses of a base class, including those registered via model loaders.
865
+ * Merges results from already-loaded classes (_modelClasses) and async loader entries with matching `extends` declarations.
866
+ * Loader-resolved classes are validated with `isInheritedFrom`; mismatches are warned and excluded.
867
+ * @param {string | ModelConstructor} baseClass Base class name or constructor
868
+ * @param {(ModelClass: ModelConstructor, className: string) => boolean} [filter] Optional filter function
869
+ * @returns {Promise<Map<string, ModelConstructor>>} Model classes that are subclasses of the base class
870
+ */
871
+ public async getSubclassesOfAsync(
872
+ baseClass: string | ModelConstructor,
873
+ filter?: (ModelClass: ModelConstructor, className: string) => boolean,
874
+ ): Promise<Map<string, ModelConstructor>> {
875
+ const baseClassName = typeof baseClass === 'string' ? baseClass : baseClass.name;
876
+
877
+ // If baseClass is a string and not yet loaded, try to resolve it first
878
+ let parentModelClass: ModelConstructor | undefined;
879
+ if (typeof baseClass === 'string') {
880
+ if (!this.getModelClass(baseClass)) {
881
+ await this.ensureModel(baseClass);
882
+ }
883
+ parentModelClass = this.getModelClass(baseClass);
884
+ } else {
885
+ parentModelClass = baseClass;
886
+ }
887
+
888
+ if (!parentModelClass) {
889
+ return new Map();
890
+ }
891
+
892
+ // Step 1: Collect already-loaded subclasses from _modelClasses
893
+ const result = this.getSubclassesOf(parentModelClass, filter);
894
+
895
+ // Step 2: Find unloaded loaders whose extends includes baseClassName
896
+ const loaderCandidates: string[] = [];
897
+ for (const [name, entry] of this._modelLoaders) {
898
+ if (result.has(name) || this._modelClasses.has(name)) continue;
899
+ if (entry.extends?.includes(baseClassName)) {
900
+ loaderCandidates.push(name);
901
+ }
902
+ }
903
+
904
+ // Step 3: Resolve all matching loaders
905
+ if (loaderCandidates.length > 0) {
906
+ await this.ensureModels(loaderCandidates);
907
+ }
908
+
909
+ // Step 4: Validate resolved classes and add to result
910
+ for (const name of loaderCandidates) {
911
+ const ModelClass = this._modelClasses.get(name);
912
+ if (!ModelClass) continue;
913
+ if (!isInheritedFrom(ModelClass, parentModelClass)) {
914
+ console.warn(
915
+ `FlowEngine: Model '${name}' declares extends '${baseClassName}' but does not actually inherit from it. Skipping.`,
916
+ );
917
+ continue;
918
+ }
919
+ if (!filter || filter(ModelClass, name)) {
920
+ result.set(name, ModelClass);
921
+ }
922
+ }
923
+
924
+ return result;
925
+ }
926
+
521
927
  /**
522
928
  * Create and register a model instance.
523
929
  * If an instance with the same UID exists, returns the existing instance.
@@ -608,7 +1014,6 @@ export class FlowEngine {
608
1014
 
609
1015
  while (current) {
610
1016
  if (visited.has(current)) {
611
- console.warn(`FlowEngine: resolveUse circular reference detected on '${current.name}'.`);
612
1017
  break;
613
1018
  }
614
1019
  visited.add(current);
@@ -727,7 +1132,7 @@ export class FlowEngine {
727
1132
  */
728
1133
  public removeModel(uid: string): boolean {
729
1134
  if (!this._modelInstances.has(uid)) {
730
- console.warn(`FlowEngine: Model with UID '${uid}' does not exist.`);
1135
+ this.logger.debug(`FlowEngine: Model with UID '${uid}' does not exist.`);
731
1136
  return false;
732
1137
  }
733
1138
  const modelInstance = this._modelInstances.get(uid) as FlowModel;
@@ -865,10 +1270,10 @@ export class FlowEngine {
865
1270
  * Hydrate a model into current engine from an already-existing model instance in previous engines.
866
1271
  * - Avoids repository requests when the model tree is already present in memory.
867
1272
  */
868
- private hydrateModelFromPreviousEngines<T extends FlowModel = FlowModel>(
1273
+ private async hydrateModelFromPreviousEngines<T extends FlowModel = FlowModel>(
869
1274
  options: any,
870
1275
  extra?: { delegateToParent?: boolean; delegate?: FlowContext },
871
- ): T | null {
1276
+ ): Promise<T | null> {
872
1277
  const uid = options?.uid;
873
1278
  const parentId = options?.parentId;
874
1279
  const subKey = options?.subKey;
@@ -882,7 +1287,7 @@ export class FlowEngine {
882
1287
  }
883
1288
  if (existing) {
884
1289
  const data = existing.serialize();
885
- return this.createModel<T>(data as any, extra);
1290
+ return this.createModelAsync<T>(data as any, extra);
886
1291
  }
887
1292
  }
888
1293
 
@@ -897,11 +1302,11 @@ export class FlowEngine {
897
1302
  if (!localParent) {
898
1303
  const parentData = parentFromPrev.serialize();
899
1304
  delete (parentData as any).subModels;
900
- localParent = this.createModel<FlowModel>(parentData as any, extra);
1305
+ localParent = await this.createModelAsync<FlowModel>(parentData as any, extra);
901
1306
  }
902
1307
  // Create (or reuse) the sub-model instance in current engine.
903
1308
  const modelData = modelFromPrev.serialize();
904
- const localModel = this.createModel<T>(modelData as any, extra);
1309
+ const localModel = await this.createModelAsync<T>(modelData as any, extra);
905
1310
 
906
1311
  // Mount under local parent if not mounted yet (so later lookups by parentId/subKey won't hit repo).
907
1312
  const mounted = (localParent.subModels as any)?.[subKey];
@@ -937,25 +1342,36 @@ export class FlowEngine {
937
1342
  async loadModel<T extends FlowModel = FlowModel>(options): Promise<T | null> {
938
1343
  if (!this.ensureModelRepository()) return;
939
1344
  const refresh = !!options?.refresh;
940
- if (!refresh) {
1345
+ const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1346
+ if (!refresh && !bypassLoadedPageCache) {
941
1347
  const model = this.findModelByParentId(options.parentId, options.subKey);
942
1348
  if (model) {
943
1349
  return model as T;
944
1350
  }
945
- const hydrated = this.hydrateModelFromPreviousEngines<T>(options);
1351
+ const hydrated = await this.hydrateModelFromPreviousEngines<T>(options);
946
1352
  if (hydrated) {
947
1353
  return hydrated as T;
948
1354
  }
949
1355
  }
950
1356
  const data = await this._modelRepository.findOne(options);
951
- if (!data?.uid) return null;
952
- if (refresh) {
1357
+ if (!data?.uid) {
1358
+ if (bypassLoadedPageCache) {
1359
+ this._loadedPageCache.clear(options);
1360
+ }
1361
+ return null;
1362
+ }
1363
+ if (refresh || bypassLoadedPageCache) {
953
1364
  const existing = this.getModel(data.uid);
954
1365
  if (existing) {
955
1366
  this.removeModelWithSubModels(existing.uid);
956
1367
  }
957
1368
  }
958
- return this.createModel<T>(data as any);
1369
+ const model = await this.createModelAsync<T>(data as any);
1370
+ if (bypassLoadedPageCache) {
1371
+ this._loadedPageCache.mountModelToParent(model, true);
1372
+ this._loadedPageCache.clear(options);
1373
+ }
1374
+ return model;
959
1375
  }
960
1376
 
961
1377
  /**
@@ -995,41 +1411,41 @@ export class FlowEngine {
995
1411
  ): Promise<T | null> {
996
1412
  if (!this.ensureModelRepository()) return;
997
1413
  const { uid, parentId, subKey } = options;
998
- if (uid && this._modelInstances.has(uid)) {
1414
+ const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
1415
+ if (uid && !bypassLoadedPageCache && this._modelInstances.has(uid)) {
999
1416
  return this._modelInstances.get(uid) as T;
1000
1417
  }
1001
- const m = this.findModelByParentId<T>(parentId, subKey);
1002
- if (m) {
1003
- return m;
1004
- }
1418
+ if (!bypassLoadedPageCache) {
1419
+ const m = this.findModelByParentId<T>(parentId, subKey);
1420
+ if (m) {
1421
+ return m;
1422
+ }
1005
1423
 
1006
- const hydrated = this.hydrateModelFromPreviousEngines<T>(options, extra);
1007
- if (hydrated) {
1008
- return hydrated;
1424
+ const hydrated = await this.hydrateModelFromPreviousEngines<T>(options, extra);
1425
+ if (hydrated) {
1426
+ return hydrated;
1427
+ }
1009
1428
  }
1010
1429
 
1011
1430
  const data = await this._modelRepository.findOne(options);
1012
1431
  let model: T | null = null;
1013
1432
  if (data?.uid) {
1014
- model = this.createModel<T>(data as any, extra);
1433
+ if (bypassLoadedPageCache) {
1434
+ const existing = this.getModel(data.uid);
1435
+ if (existing) {
1436
+ this.removeModelWithSubModels(existing.uid);
1437
+ }
1438
+ }
1439
+ model = await this.createModelAsync<T>(data as any, extra);
1015
1440
  } else {
1016
- model = this.createModel<T>(options, extra);
1441
+ model = await this.createModelAsync<T>(options, extra);
1017
1442
  if (!extra?.skipSave) {
1018
1443
  await model.save();
1019
1444
  }
1020
1445
  }
1021
- if (model.parent) {
1022
- const subModel = model.parent.findSubModel(model.subKey, (m) => {
1023
- return m.uid === model.uid;
1024
- });
1025
- if (subModel) {
1026
- return model;
1027
- }
1028
- if (model.subType === 'array') {
1029
- model.parent.addSubModel(model.subKey, model);
1030
- } else {
1031
- model.parent.setSubModel(model.subKey, model);
1032
- }
1446
+ this._loadedPageCache.mountModelToParent(model, bypassLoadedPageCache);
1447
+ if (bypassLoadedPageCache) {
1448
+ this._loadedPageCache.clear(options);
1033
1449
  }
1034
1450
  return model;
1035
1451
  }
@@ -1049,6 +1465,9 @@ export class FlowEngine {
1049
1465
  if (!this.ensureModelRepository()) return;
1050
1466
 
1051
1467
  const modelUid = model.uid;
1468
+ const dirtyLoadedPageKey = this._loadedPageCache.getDirtyKeyForModel(model, {
1469
+ force: !!options?.onlyStepParams,
1470
+ });
1052
1471
 
1053
1472
  // 如果这个 model 正在保存中,返回现有的保存 Promise
1054
1473
  if (this._savingModels.has(modelUid)) {
@@ -1062,6 +1481,7 @@ export class FlowEngine {
1062
1481
 
1063
1482
  try {
1064
1483
  const result = await savePromise;
1484
+ this._loadedPageCache.markDirty(dirtyLoadedPageKey);
1065
1485
  return result;
1066
1486
  } finally {
1067
1487
  // 无论成功还是失败,都要清除保存状态
@@ -1098,11 +1518,16 @@ export class FlowEngine {
1098
1518
  * @returns {Promise<boolean>} Whether destroyed successfully
1099
1519
  */
1100
1520
  async destroyModel(uid: string) {
1101
- if (this.ensureModelRepository()) {
1521
+ const modelInstance = this._modelInstances.get(uid) as FlowModel;
1522
+ const dirtyLoadedPageKey = this._loadedPageCache.getDirtyKeyForModel(modelInstance);
1523
+ const hasModelRepository = this.ensureModelRepository();
1524
+ if (hasModelRepository) {
1102
1525
  await this._modelRepository.destroy(uid);
1103
1526
  }
1104
1527
 
1105
- const modelInstance = this._modelInstances.get(uid) as FlowModel;
1528
+ if (hasModelRepository) {
1529
+ this._loadedPageCache.markDirty(dirtyLoadedPageKey);
1530
+ }
1106
1531
  const parent = modelInstance?.parent;
1107
1532
  const result = this.removeModel(uid);
1108
1533
  parent && parent.emitter.emit('onSubModelDestroyed', modelInstance);
@@ -1206,17 +1631,25 @@ export class FlowEngine {
1206
1631
 
1207
1632
  /**
1208
1633
  * Move a model instance within its parent model.
1209
- * @param {any} sourceId Source model UID
1210
- * @param {any} targetId Target model UID
1634
+ * @param {string | number} sourceId Source model UID
1635
+ * @param {string | number} targetId Target model UID
1211
1636
  * @returns {Promise<void>} No return value
1212
1637
  */
1213
- async moveModel(sourceId: any, targetId: any, options?: PersistOptions): Promise<void> {
1214
- const sourceModel = this.getModel(sourceId);
1215
- const targetModel = this.getModel(targetId);
1638
+ async moveModel(sourceId: string | number, targetId: string | number, options?: PersistOptions): Promise<void> {
1639
+ const sourceUid = String(sourceId);
1640
+ const targetUid = String(targetId);
1641
+ if (!sourceUid || !targetUid || sourceUid === targetUid) {
1642
+ return;
1643
+ }
1644
+
1645
+ const sourceModel = this.getModel(sourceUid);
1646
+ const targetModel = this.getModel(targetUid);
1216
1647
  if (!sourceModel || !targetModel) {
1217
1648
  console.warn(`FlowEngine: Cannot move model. Source or target model not found.`);
1218
1649
  return;
1219
1650
  }
1651
+ let position: 'before' | 'after' = 'after';
1652
+ const dirtyLoadedPageKey = this._loadedPageCache.getDirtyKeyForModel(sourceModel);
1220
1653
  const move = (sourceModel: FlowModel, targetModel: FlowModel) => {
1221
1654
  if (!sourceModel.parent || !targetModel.parent || sourceModel.parent !== targetModel.parent) {
1222
1655
  console.error('FlowModel.moveTo: Both models must have the same parent to perform move operation.');
@@ -1246,6 +1679,8 @@ export class FlowEngine {
1246
1679
  return false;
1247
1680
  }
1248
1681
 
1682
+ position = currentIndex < targetIndex ? 'after' : 'before';
1683
+
1249
1684
  // 使用splice直接移动数组元素(O(n)比排序O(n log n)更快)
1250
1685
  const [movedModel] = subModelsCopy.splice(currentIndex, 1);
1251
1686
  subModelsCopy.splice(targetIndex, 0, movedModel);
@@ -1260,10 +1695,14 @@ export class FlowEngine {
1260
1695
 
1261
1696
  return true;
1262
1697
  };
1263
- move(sourceModel, targetModel);
1698
+ const moved = move(sourceModel, targetModel);
1699
+ if (!moved) {
1700
+ return;
1701
+ }
1702
+
1264
1703
  if (options?.persist !== false && this.ensureModelRepository()) {
1265
- const position = sourceModel.sortIndex - targetModel.sortIndex > 0 ? 'after' : 'before';
1266
- await this._modelRepository.move(sourceId, targetId, position);
1704
+ await this._modelRepository.move(sourceUid, targetUid, position);
1705
+ this._loadedPageCache.markDirty(dirtyLoadedPageKey);
1267
1706
  }
1268
1707
  // 触发事件以通知其他部分模型已移动
1269
1708
  sourceModel.parent.emitter.emit('onSubModelMoved', { source: sourceModel, target: targetModel });