@nocobase/flow-engine 2.1.0-alpha.14 → 2.1.0-alpha.15

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.
@@ -461,6 +461,12 @@ const _Collection = class _Collection {
461
461
  }
462
462
  get titleCollectionField() {
463
463
  const titleFieldName = this.options.titleField || this.filterTargetKey;
464
+ if (Array.isArray(titleFieldName)) {
465
+ if (titleFieldName.length !== 1) {
466
+ return void 0;
467
+ }
468
+ return this.getField(titleFieldName[0]);
469
+ }
464
470
  const titleCollectionField = this.getField(titleFieldName);
465
471
  return titleCollectionField;
466
472
  }
@@ -57,6 +57,7 @@ __export(flowContext_exports, {
57
57
  });
58
58
  module.exports = __toCommonJS(flowContext_exports);
59
59
  var import_reactive = require("@formily/reactive");
60
+ var import_axios = __toESM(require("axios"));
60
61
  var antd = __toESM(require("antd"));
61
62
  var import_lodash = __toESM(require("lodash"));
62
63
  var import_qs = __toESM(require("qs"));
@@ -81,6 +82,28 @@ var import_dayjs = __toESM(require("dayjs"));
81
82
  var import_runjsLibs = require("./runjsLibs");
82
83
  var import_runjsModuleLoader = require("./utils/runjsModuleLoader");
83
84
  var _proxy, _FlowContext_instances, createChildNodes_fn, findMetaByPath_fn, findMetaInDelegatesDeep_fn, findMetaInProperty_fn, resolvePathInMeta_fn, resolvePathInMetaAsync_fn, buildParentTitles_fn, toTreeNode_fn;
85
+ function normalizePathname(pathname) {
86
+ return pathname.endsWith("/") ? pathname : `${pathname}/`;
87
+ }
88
+ __name(normalizePathname, "normalizePathname");
89
+ function shouldBypassApiClient(url, app) {
90
+ try {
91
+ const requestUrl = new URL(url);
92
+ if (!["http:", "https:"].includes(requestUrl.protocol)) {
93
+ return false;
94
+ }
95
+ if (!(app == null ? void 0 : app.getApiUrl)) {
96
+ return true;
97
+ }
98
+ const apiUrl = new URL(app.getApiUrl());
99
+ const apiPath = normalizePathname(apiUrl.pathname);
100
+ const requestPath = normalizePathname(requestUrl.pathname);
101
+ return requestUrl.origin !== apiUrl.origin || !requestPath.startsWith(apiPath);
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+ __name(shouldBypassApiClient, "shouldBypassApiClient");
84
107
  function isRecordRefLike(val) {
85
108
  return !!(val && typeof val === "object" && "collection" in val && "filterByTk" in val);
86
109
  }
@@ -2211,6 +2234,10 @@ const _BaseFlowEngineContext = class _BaseFlowEngineContext extends FlowContext
2211
2234
  return this.engine.getModel(modelName, searchInPreviousEngines);
2212
2235
  });
2213
2236
  this.defineMethod("request", (options) => {
2237
+ const app = this.app;
2238
+ if (typeof (options == null ? void 0 : options.url) === "string" && shouldBypassApiClient(options.url, app)) {
2239
+ return import_axios.default.request(options);
2240
+ }
2214
2241
  return this.api.request(options);
2215
2242
  });
2216
2243
  this.defineMethod(
@@ -8,7 +8,7 @@ import { FlowResource } from './resources';
8
8
  import { Emitter } from './emitter';
9
9
  import ModelOperationScheduler from './scheduler/ModelOperationScheduler';
10
10
  import type { ScheduleOptions, ScheduledCancel } from './scheduler/ModelOperationScheduler';
11
- import type { ActionDefinition, ApplyFlowCacheEntry, CreateModelOptions, EnsureBatchResult, EventDefinition, FlowModelLoaderMap, FlowModelOptions, IFlowModelRepository, ModelConstructor, PersistOptions, ResourceType } from './types';
11
+ import type { ActionDefinition, ApplyFlowCacheEntry, CreateModelOptions, EnsureBatchResult, EventDefinition, FlowModelLoaderInputMap, FlowModelOptions, IFlowModelRepository, ModelConstructor, PersistOptions, ResourceType } from './types';
12
12
  /**
13
13
  * FlowEngine is the core class of the flow engine, responsible for managing flow models, actions, model repository, and more.
14
14
  * It provides capabilities for registering, creating, finding, persisting, replacing, and moving models.
@@ -262,16 +262,19 @@ export declare class FlowEngine {
262
262
  registerModels(models: Record<string, ModelConstructor | typeof FlowModel<any>>): void;
263
263
  /**
264
264
  * Register multiple model loader entries.
265
- * @param {FlowModelLoaderMap} loaders Model loader entry map, key is model name, value is the model loader entry
265
+ * The `extends` field declares parent class(es) for async subclass discovery via `getSubclassesOfAsync`.
266
+ * It accepts `string | ModelConstructor | (string | ModelConstructor)[]` and is normalized to `string[]` internally.
267
+ * @param {FlowModelLoaderInputMap} loaders Model loader input map
266
268
  * @returns {void}
267
269
  * @example
268
270
  * flowEngine.registerModelLoaders({
269
271
  * DemoModel: {
272
+ * extends: 'BaseModel',
270
273
  * loader: () => import('./models/DemoModel'),
271
274
  * },
272
275
  * });
273
276
  */
274
- registerModelLoaders(loaders: FlowModelLoaderMap): void;
277
+ registerModelLoaders(loaders: FlowModelLoaderInputMap): void;
275
278
  /**
276
279
  * Get a registered model class (constructor) asynchronously.
277
280
  * This will first ensure the model loader entry is resolved.
@@ -381,6 +384,15 @@ export declare class FlowEngine {
381
384
  * @returns {Map<string, ModelConstructor>} Model classes inherited from base class and passed the filter
382
385
  */
383
386
  getSubclassesOf(baseClass: string | ModelConstructor, filter?: (ModelClass: ModelConstructor, className: string) => boolean): Map<string, ModelConstructor>;
387
+ /**
388
+ * Asynchronously get all subclasses of a base class, including those registered via model loaders.
389
+ * Merges results from already-loaded classes (_modelClasses) and async loader entries with matching `extends` declarations.
390
+ * Loader-resolved classes are validated with `isInheritedFrom`; mismatches are warned and excluded.
391
+ * @param {string | ModelConstructor} baseClass Base class name or constructor
392
+ * @param {(ModelClass: ModelConstructor, className: string) => boolean} [filter] Optional filter function
393
+ * @returns {Promise<Map<string, ModelConstructor>>} Model classes that are subclasses of the base class
394
+ */
395
+ getSubclassesOfAsync(baseClass: string | ModelConstructor, filter?: (ModelClass: ModelConstructor, className: string) => boolean): Promise<Map<string, ModelConstructor>>;
384
396
  /**
385
397
  * Create and register a model instance.
386
398
  * If an instance with the same UID exists, returns the existing instance.
package/lib/flowEngine.js CHANGED
@@ -418,21 +418,31 @@ const _FlowEngine = class _FlowEngine {
418
418
  }
419
419
  /**
420
420
  * Register multiple model loader entries.
421
- * @param {FlowModelLoaderMap} loaders Model loader entry map, key is model name, value is the model loader entry
421
+ * The `extends` field declares parent class(es) for async subclass discovery via `getSubclassesOfAsync`.
422
+ * It accepts `string | ModelConstructor | (string | ModelConstructor)[]` and is normalized to `string[]` internally.
423
+ * @param {FlowModelLoaderInputMap} loaders Model loader input map
422
424
  * @returns {void}
423
425
  * @example
424
426
  * flowEngine.registerModelLoaders({
425
427
  * DemoModel: {
428
+ * extends: 'BaseModel',
426
429
  * loader: () => import('./models/DemoModel'),
427
430
  * },
428
431
  * });
429
432
  */
430
433
  registerModelLoaders(loaders) {
431
434
  let changed = false;
432
- for (const [name, entry] of Object.entries(loaders)) {
435
+ for (const [name, input] of Object.entries(loaders)) {
433
436
  if (this._modelLoaders.has(name)) {
434
437
  console.warn(`FlowEngine: Model loader with name '${name}' is already registered and will be overwritten.`);
435
438
  }
439
+ const entry = {
440
+ loader: input.loader
441
+ };
442
+ if (input.extends != null) {
443
+ const raw = Array.isArray(input.extends) ? input.extends : [input.extends];
444
+ entry.extends = raw.map((item) => typeof item === "string" ? item : item.name);
445
+ }
436
446
  this._modelLoaders.set(name, entry);
437
447
  changed = true;
438
448
  }
@@ -737,6 +747,55 @@ const _FlowEngine = class _FlowEngine {
737
747
  }
738
748
  return result;
739
749
  }
750
+ /**
751
+ * Asynchronously get all subclasses of a base class, including those registered via model loaders.
752
+ * Merges results from already-loaded classes (_modelClasses) and async loader entries with matching `extends` declarations.
753
+ * Loader-resolved classes are validated with `isInheritedFrom`; mismatches are warned and excluded.
754
+ * @param {string | ModelConstructor} baseClass Base class name or constructor
755
+ * @param {(ModelClass: ModelConstructor, className: string) => boolean} [filter] Optional filter function
756
+ * @returns {Promise<Map<string, ModelConstructor>>} Model classes that are subclasses of the base class
757
+ */
758
+ async getSubclassesOfAsync(baseClass, filter) {
759
+ var _a;
760
+ const baseClassName = typeof baseClass === "string" ? baseClass : baseClass.name;
761
+ let parentModelClass;
762
+ if (typeof baseClass === "string") {
763
+ if (!this.getModelClass(baseClass)) {
764
+ await this.ensureModel(baseClass);
765
+ }
766
+ parentModelClass = this.getModelClass(baseClass);
767
+ } else {
768
+ parentModelClass = baseClass;
769
+ }
770
+ if (!parentModelClass) {
771
+ return /* @__PURE__ */ new Map();
772
+ }
773
+ const result = this.getSubclassesOf(parentModelClass, filter);
774
+ const loaderCandidates = [];
775
+ for (const [name, entry] of this._modelLoaders) {
776
+ if (result.has(name) || this._modelClasses.has(name)) continue;
777
+ if ((_a = entry.extends) == null ? void 0 : _a.includes(baseClassName)) {
778
+ loaderCandidates.push(name);
779
+ }
780
+ }
781
+ if (loaderCandidates.length > 0) {
782
+ await this.ensureModels(loaderCandidates);
783
+ }
784
+ for (const name of loaderCandidates) {
785
+ const ModelClass = this._modelClasses.get(name);
786
+ if (!ModelClass) continue;
787
+ if (!(0, import_utils.isInheritedFrom)(ModelClass, parentModelClass)) {
788
+ console.warn(
789
+ `FlowEngine: Model '${name}' declares extends '${baseClassName}' but does not actually inherit from it. Skipping.`
790
+ );
791
+ continue;
792
+ }
793
+ if (!filter || filter(ModelClass, name)) {
794
+ result.set(name, ModelClass);
795
+ }
796
+ }
797
+ return result;
798
+ }
740
799
  /**
741
800
  * Create and register a model instance.
742
801
  * If an instance with the same UID exists, returns the existing instance.
@@ -34,6 +34,7 @@
34
34
  "Failed to destroy model after creation error": "Failed to destroy model after creation error",
35
35
  "Failed to get action {{action}}": "Failed to get action {{action}}",
36
36
  "Failed to get configurable flows for model {{model}}": "Failed to get configurable flows for model {{model}}",
37
+ "Attributes are unavailable before selecting a record": "Attributes are unavailable before selecting a record",
37
38
  "Failed to import FormDialog": "Failed to import FormDialog",
38
39
  "Failed to import FormDialog or FormStep": "Failed to import FormDialog or FormStep",
39
40
  "Failed to import Formily components": "Failed to import Formily components",
@@ -43,6 +43,7 @@ export declare const locales: {
43
43
  "Failed to destroy model after creation error": string;
44
44
  "Failed to get action {{action}}": string;
45
45
  "Failed to get configurable flows for model {{model}}": string;
46
+ "Attributes are unavailable before selecting a record": string;
46
47
  "Failed to import FormDialog": string;
47
48
  "Failed to import FormDialog or FormStep": string;
48
49
  "Failed to import Formily components": string;
@@ -120,6 +121,7 @@ export declare const locales: {
120
121
  "Failed to destroy model after creation error": string;
121
122
  "Failed to get action {{action}}": string;
122
123
  "Failed to get configurable flows for model {{model}}": string;
124
+ "Attributes are unavailable before selecting a record": string;
123
125
  "Failed to import FormDialog": string;
124
126
  "Failed to import FormDialog or FormStep": string;
125
127
  "Failed to import Formily components": string;
@@ -31,6 +31,7 @@
31
31
  "Failed to destroy model after creation error": "创建错误后销毁模型失败",
32
32
  "Failed to get action {{action}}": "获取 action '{{action}}' 失败",
33
33
  "Failed to get configurable flows for model {{model}}": "获取模型 '{{model}}' 的可配置 flows 失败",
34
+ "Attributes are unavailable before selecting a record": "选择记录之前,当前项属性不可用",
34
35
  "Failed to import FormDialog": "导入 FormDialog 失败",
35
36
  "Failed to import FormDialog or FormStep": "导入 FormDialog 或 FormStep 失败",
36
37
  "Failed to import Formily components": "导入 Formily 组件失败",
@@ -51,8 +51,31 @@ const observer = /* @__PURE__ */ __name((Component, options) => {
51
51
  const ctxRef = (0, import_react.useRef)(ctx);
52
52
  ctxRef.current = ctx;
53
53
  const pendingDisposerRef = (0, import_react.useRef)(null);
54
+ const pendingTimerRef = (0, import_react.useRef)(null);
55
+ const clearPendingDisposer = /* @__PURE__ */ __name(() => {
56
+ if (pendingDisposerRef.current) {
57
+ pendingDisposerRef.current();
58
+ pendingDisposerRef.current = null;
59
+ }
60
+ }, "clearPendingDisposer");
61
+ const clearPendingTimer = /* @__PURE__ */ __name(() => {
62
+ if (pendingTimerRef.current) {
63
+ clearTimeout(pendingTimerRef.current);
64
+ pendingTimerRef.current = null;
65
+ }
66
+ }, "clearPendingTimer");
67
+ const isContextActive = /* @__PURE__ */ __name(() => {
68
+ var _a, _b;
69
+ const pageActive = getPageActive(ctxRef.current);
70
+ const tabActive = (_b = (_a = ctxRef.current) == null ? void 0 : _a.tabActive) == null ? void 0 : _b.value;
71
+ return pageActive !== false && tabActive !== false;
72
+ }, "isContextActive");
54
73
  (0, import_react.useEffect)(() => {
55
74
  return () => {
75
+ if (pendingTimerRef.current) {
76
+ clearTimeout(pendingTimerRef.current);
77
+ pendingTimerRef.current = null;
78
+ }
56
79
  if (pendingDisposerRef.current) {
57
80
  pendingDisposerRef.current();
58
81
  pendingDisposerRef.current = null;
@@ -62,30 +85,37 @@ const observer = /* @__PURE__ */ __name((Component, options) => {
62
85
  const ObservedComponent = (0, import_react.useMemo)(
63
86
  () => (0, import_reactive_react.observer)(Component, {
64
87
  scheduler(updater) {
65
- var _a, _b;
66
- const pageActive = getPageActive(ctxRef.current);
67
- const tabActive = (_b = (_a = ctxRef.current) == null ? void 0 : _a.tabActive) == null ? void 0 : _b.value;
68
- if (pageActive === false || tabActive === false) {
69
- setTimeout(() => {
88
+ if (!isContextActive()) {
89
+ if (pendingTimerRef.current || pendingDisposerRef.current) {
90
+ return;
91
+ }
92
+ pendingTimerRef.current = setTimeout(() => {
93
+ pendingTimerRef.current = null;
70
94
  if (pendingDisposerRef.current) {
71
95
  return;
72
96
  }
73
- const disposer = (0, import_reactive.autorun)(() => {
74
- var _a2, _b2, _c, _d, _e, _f;
75
- if (((_b2 = (_a2 = ctxRef.current) == null ? void 0 : _a2.pageActive) == null ? void 0 : _b2.value) && (((_d = (_c = ctxRef.current) == null ? void 0 : _c.tabActive) == null ? void 0 : _d.value) === true || ((_f = (_e = ctxRef.current) == null ? void 0 : _e.tabActive) == null ? void 0 : _f.value) === void 0)) {
97
+ if (isContextActive()) {
98
+ updater();
99
+ return;
100
+ }
101
+ pendingDisposerRef.current = (0, import_reactive.reaction)(
102
+ () => isContextActive(),
103
+ (active) => {
104
+ if (!active) {
105
+ return;
106
+ }
107
+ clearPendingDisposer();
76
108
  updater();
77
- disposer == null ? void 0 : disposer();
78
- pendingDisposerRef.current = null;
109
+ },
110
+ {
111
+ name: "FlowObserverPendingUpdate"
79
112
  }
80
- });
81
- pendingDisposerRef.current = disposer;
113
+ );
82
114
  });
83
115
  return;
84
116
  }
85
- if (pendingDisposerRef.current) {
86
- pendingDisposerRef.current();
87
- pendingDisposerRef.current = null;
88
- }
117
+ clearPendingTimer();
118
+ clearPendingDisposer();
89
119
  updater();
90
120
  },
91
121
  ...options
package/lib/types.d.ts CHANGED
@@ -316,17 +316,28 @@ export type FlowModelLoaderResult = ModelConstructor | {
316
316
  */
317
317
  export type FlowModelLoader = () => Promise<FlowModelLoaderResult>;
318
318
  /**
319
- * FlowModel loader entry.
320
- * Future contribution-style fields are intentionally kept as commented placeholders in phase A
321
- * and are not consumed by current runtime logic.
319
+ * FlowModel loader entry (normalized internal form).
322
320
  */
323
321
  export interface FlowModelLoaderEntry {
324
322
  loader: FlowModelLoader;
323
+ extends?: string[];
325
324
  }
326
325
  /**
327
- * FlowModel loader entry map.
326
+ * FlowModel loader input (user-facing form for registerModelLoaders).
327
+ * The `extends` field accepts flexible formats that will be normalized to `string[]` at registration time.
328
+ */
329
+ export interface FlowModelLoaderInput {
330
+ loader: FlowModelLoader;
331
+ extends?: string | ModelConstructor | (string | ModelConstructor)[];
332
+ }
333
+ /**
334
+ * FlowModel loader entry map (normalized internal form).
328
335
  */
329
336
  export type FlowModelLoaderMap = Record<string, FlowModelLoaderEntry>;
337
+ /**
338
+ * FlowModel loader input map (user-facing form for registerModelLoaders).
339
+ */
340
+ export type FlowModelLoaderInputMap = Record<string, FlowModelLoaderInput>;
330
341
  /**
331
342
  * Batch ensure result.
332
343
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.1.0-alpha.14",
3
+ "version": "2.1.0-alpha.15",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,9 +8,10 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.1.0-alpha.14",
12
- "@nocobase/shared": "2.1.0-alpha.14",
11
+ "@nocobase/sdk": "2.1.0-alpha.15",
12
+ "@nocobase/shared": "2.1.0-alpha.15",
13
13
  "ahooks": "^3.7.2",
14
+ "axios": "^1.7.0",
14
15
  "dayjs": "^1.11.9",
15
16
  "dompurify": "^3.0.2",
16
17
  "lodash": "^4.x",
@@ -36,5 +37,5 @@
36
37
  ],
37
38
  "author": "NocoBase Team",
38
39
  "license": "Apache-2.0",
39
- "gitHead": "d8735b541de0ff9557bba704de49c799b4962672"
40
+ "gitHead": "7c86e75b0af4b9f532c8ebf5ef96a7423b0ab60e"
40
41
  }
@@ -189,4 +189,170 @@ describe('FlowEngine', () => {
189
189
  expect(mounted?.uid).toBe('c3');
190
190
  });
191
191
  });
192
+
193
+ describe('getSubclassesOfAsync', () => {
194
+ it('should return async-loaded subclasses matching extends declaration', async () => {
195
+ class AsyncSubModelD extends BaseModel {}
196
+ class AsyncSubModelE extends BaseModel {}
197
+
198
+ engine.registerModelLoaders({
199
+ AsyncSubModelD: {
200
+ extends: 'BaseModel',
201
+ loader: async () => ({ AsyncSubModelD }),
202
+ },
203
+ AsyncSubModelE: {
204
+ extends: 'BaseModel',
205
+ loader: async () => ({ AsyncSubModelE }),
206
+ },
207
+ });
208
+
209
+ const result = await engine.getSubclassesOfAsync(BaseModel);
210
+
211
+ // Sync-registered subclasses
212
+ expect(result.has('SubModelA')).toBe(true);
213
+ expect(result.has('SubModelB')).toBe(true);
214
+ expect(result.has('SubModelC')).toBe(true);
215
+ // Async-loaded subclasses
216
+ expect(result.has('AsyncSubModelD')).toBe(true);
217
+ expect(result.has('AsyncSubModelE')).toBe(true);
218
+ // Base class excluded
219
+ expect(result.has('BaseModel')).toBe(false);
220
+ });
221
+
222
+ it('should merge sync-registered and async-loaded subclasses', async () => {
223
+ class AsyncSubModel extends BaseModel {}
224
+
225
+ engine.registerModelLoaders({
226
+ AsyncSubModel: {
227
+ extends: 'BaseModel',
228
+ loader: async () => ({ AsyncSubModel }),
229
+ },
230
+ });
231
+
232
+ const result = await engine.getSubclassesOfAsync('BaseModel');
233
+
234
+ // Sync: SubModelA, SubModelB, SubModelC
235
+ expect(result.has('SubModelA')).toBe(true);
236
+ expect(result.has('SubModelB')).toBe(true);
237
+ expect(result.has('SubModelC')).toBe(true);
238
+ // Async
239
+ expect(result.has('AsyncSubModel')).toBe(true);
240
+ expect(result.size).toBe(4);
241
+ });
242
+
243
+ it('should support extends as string array (multiple parents)', async () => {
244
+ class AnotherBase extends FlowModel {}
245
+ class MultiParentModel extends BaseModel {}
246
+
247
+ engine.registerModels({ AnotherBase });
248
+ engine.registerModelLoaders({
249
+ MultiParentModel: {
250
+ extends: ['BaseModel', 'AnotherBase'],
251
+ loader: async () => ({ MultiParentModel }),
252
+ },
253
+ });
254
+
255
+ const resultBase = await engine.getSubclassesOfAsync(BaseModel);
256
+ expect(resultBase.has('MultiParentModel')).toBe(true);
257
+
258
+ // Also found by AnotherBase (even though actual inheritance is from BaseModel, not AnotherBase)
259
+ // The extends declaration triggers loading, but isInheritedFrom validation will exclude it from AnotherBase results
260
+ const resultAnother = await engine.getSubclassesOfAsync(AnotherBase);
261
+ expect(resultAnother.has('MultiParentModel')).toBe(false);
262
+ });
263
+
264
+ it('should support extends as ModelConstructor', async () => {
265
+ class AsyncCtorSubModel extends BaseModel {}
266
+
267
+ engine.registerModelLoaders({
268
+ AsyncCtorSubModel: {
269
+ extends: BaseModel,
270
+ loader: async () => ({ AsyncCtorSubModel }),
271
+ },
272
+ });
273
+
274
+ const result = await engine.getSubclassesOfAsync(BaseModel);
275
+ expect(result.has('AsyncCtorSubModel')).toBe(true);
276
+ });
277
+
278
+ it('should validate actual inheritance and warn on mismatch', async () => {
279
+ class UnrelatedModel extends FlowModel {}
280
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
281
+
282
+ engine.registerModelLoaders({
283
+ UnrelatedModel: {
284
+ extends: 'BaseModel',
285
+ loader: async () => ({ UnrelatedModel }),
286
+ },
287
+ });
288
+
289
+ const result = await engine.getSubclassesOfAsync(BaseModel);
290
+ expect(result.has('UnrelatedModel')).toBe(false);
291
+ expect(warnSpy).toHaveBeenCalledWith(
292
+ expect.stringContaining("declares extends 'BaseModel' but does not actually inherit from it"),
293
+ );
294
+
295
+ warnSpy.mockRestore();
296
+ });
297
+
298
+ it('should resolve base class from loaders if not in _modelClasses', async () => {
299
+ const freshEngine = new FlowEngine();
300
+
301
+ class LazyBase extends FlowModel {}
302
+ class LazySub extends LazyBase {}
303
+
304
+ freshEngine.registerModelLoaders({
305
+ LazyBase: {
306
+ loader: async () => ({ LazyBase }),
307
+ },
308
+ LazySub: {
309
+ extends: 'LazyBase',
310
+ loader: async () => ({ LazySub }),
311
+ },
312
+ });
313
+
314
+ const result = await freshEngine.getSubclassesOfAsync('LazyBase');
315
+ expect(result.has('LazySub')).toBe(true);
316
+ expect(result.size).toBe(1);
317
+ });
318
+
319
+ it('should return empty Map when base class cannot be found', async () => {
320
+ const result = await engine.getSubclassesOfAsync('NonExistentModel');
321
+ expect(result.size).toBe(0);
322
+ });
323
+
324
+ it('should support filter parameter on both sync and async sources', async () => {
325
+ class FilteredAsyncModel extends BaseModel {}
326
+
327
+ engine.registerModelLoaders({
328
+ FilteredAsyncModel: {
329
+ extends: 'BaseModel',
330
+ loader: async () => ({ FilteredAsyncModel }),
331
+ },
332
+ });
333
+
334
+ const result = await engine.getSubclassesOfAsync(BaseModel, (_ModelClass, name) => name.startsWith('SubModelA'));
335
+
336
+ // Only SubModelA passes the filter (SubModelB, SubModelC, FilteredAsyncModel excluded)
337
+ expect(result.has('SubModelA')).toBe(true);
338
+ expect(result.has('SubModelB')).toBe(false);
339
+ expect(result.has('SubModelC')).toBe(false);
340
+ expect(result.has('FilteredAsyncModel')).toBe(false);
341
+ });
342
+
343
+ it('should not include loaders without extends declaration', async () => {
344
+ class NoExtendsModel extends BaseModel {}
345
+
346
+ engine.registerModelLoaders({
347
+ NoExtendsModel: {
348
+ loader: async () => ({ NoExtendsModel }),
349
+ },
350
+ });
351
+
352
+ const result = await engine.getSubclassesOfAsync(BaseModel);
353
+ // Only sync-registered subclasses; NoExtendsModel has no extends, so not discovered
354
+ expect(result.has('NoExtendsModel')).toBe(false);
355
+ expect(result.has('SubModelA')).toBe(true);
356
+ });
357
+ });
192
358
  });
@@ -7,7 +7,8 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it, vi } from 'vitest';
10
+ import axios from 'axios';
11
+ import { describe, expect, it, vi, afterEach } from 'vitest';
11
12
  import { FlowContext, FlowRuntimeContext, FlowRunJSContext, type PropertyMetaFactory } from '../flowContext';
12
13
  import { FlowEngine } from '../flowEngine';
13
14
  import { FlowModel } from '../models/flowModel';
@@ -1630,6 +1631,69 @@ describe('runAction delegation from runtime context', () => {
1630
1631
  });
1631
1632
  });
1632
1633
 
1634
+ describe('FlowContext request defaults', () => {
1635
+ class RequestModel extends FlowModel {}
1636
+
1637
+ afterEach(() => {
1638
+ vi.restoreAllMocks();
1639
+ });
1640
+
1641
+ const createRequestContext = () => {
1642
+ const engine = new FlowEngine();
1643
+ engine.registerModels({ RequestModel });
1644
+
1645
+ const apiRequest = vi.fn(async (options) => options);
1646
+ const app = {
1647
+ getApiUrl(pathname = '') {
1648
+ return 'https://app.example.com/api/'.replace(/\/$/g, '') + '/' + pathname.replace(/^\//g, '');
1649
+ },
1650
+ };
1651
+
1652
+ engine.context.defineProperty('api', { value: { request: apiRequest } as any });
1653
+ engine.context.defineProperty('app', { value: app });
1654
+
1655
+ const model = engine.createModel({ use: 'RequestModel' });
1656
+ const ctx = new FlowRuntimeContext(model, 'flow');
1657
+ const directAxiosRequest = vi.spyOn(axios, 'request').mockResolvedValue({ data: {} } as any);
1658
+
1659
+ return { ctx, apiRequest, directAxiosRequest };
1660
+ };
1661
+
1662
+ it.each([
1663
+ ['apiClient', 'users:list', 'api'],
1664
+ ['apiClient', '/api/users:list', 'api'],
1665
+ ['apiClient', 'https://app.example.com/api/users:list', 'api'],
1666
+ ['direct axios', 'https://app.example.com/custom-api/users', 'axios'],
1667
+ ])('should use %s for %s', async (_target, url, expected) => {
1668
+ const { ctx, apiRequest, directAxiosRequest } = createRequestContext();
1669
+
1670
+ await ctx.request({ url, method: 'get' });
1671
+
1672
+ if (expected === 'api') {
1673
+ expect(apiRequest).toHaveBeenCalledTimes(1);
1674
+ expect(directAxiosRequest).not.toHaveBeenCalled();
1675
+ return;
1676
+ }
1677
+
1678
+ expect(directAxiosRequest).toHaveBeenCalledTimes(1);
1679
+ expect(apiRequest).not.toHaveBeenCalled();
1680
+ });
1681
+
1682
+ it('should use direct axios for cross-origin absolute urls', async () => {
1683
+ const { ctx, apiRequest, directAxiosRequest } = createRequestContext();
1684
+
1685
+ await ctx.request({ url: 'https://api.example.com/users', method: 'get', skipAuth: false });
1686
+
1687
+ expect(directAxiosRequest).toHaveBeenCalledTimes(1);
1688
+ expect(apiRequest).not.toHaveBeenCalled();
1689
+ expect(directAxiosRequest.mock.calls[0][0]).toMatchObject({
1690
+ url: 'https://api.example.com/users',
1691
+ method: 'get',
1692
+ skipAuth: false,
1693
+ });
1694
+ });
1695
+ });
1696
+
1633
1697
  describe('FlowContext delayed meta loading', () => {
1634
1698
  // 测试场景:属性定义时 meta 为异步函数,首次访问时延迟加载
1635
1699
  // 输入:属性带有异步 meta 函数
@@ -501,6 +501,12 @@ export class Collection {
501
501
 
502
502
  get titleCollectionField() {
503
503
  const titleFieldName = this.options.titleField || this.filterTargetKey;
504
+ if (Array.isArray(titleFieldName)) {
505
+ if (titleFieldName.length !== 1) {
506
+ return undefined;
507
+ }
508
+ return this.getField(titleFieldName[0]);
509
+ }
504
510
  const titleCollectionField = this.getField(titleFieldName);
505
511
  return titleCollectionField;
506
512
  }
@@ -11,6 +11,7 @@ import { ISchema } from '@formily/json-schema';
11
11
  import { observable } from '@formily/reactive';
12
12
  import { APIClient, RequestOptions } from '@nocobase/sdk';
13
13
  import type { Router } from '@remix-run/router';
14
+ import axios from 'axios';
14
15
  import { MessageInstance } from 'antd/es/message/interface';
15
16
  import * as antd from 'antd';
16
17
  import type { HookAPI } from 'antd/es/modal/useModal';
@@ -58,6 +59,31 @@ import dayjs from 'dayjs';
58
59
  import { externalReactRender, setupRunJSLibs } from './runjsLibs';
59
60
  import { runjsImportAsync, runjsImportModule, runjsRequireAsync } from './utils/runjsModuleLoader';
60
61
 
62
+ function normalizePathname(pathname: string) {
63
+ return pathname.endsWith('/') ? pathname : `${pathname}/`;
64
+ }
65
+
66
+ function shouldBypassApiClient(url: string, app?: { getApiUrl?: (pathname?: string) => string }) {
67
+ try {
68
+ const requestUrl = new URL(url);
69
+ if (!['http:', 'https:'].includes(requestUrl.protocol)) {
70
+ return false;
71
+ }
72
+
73
+ if (!app?.getApiUrl) {
74
+ return true;
75
+ }
76
+
77
+ const apiUrl = new URL(app.getApiUrl());
78
+ const apiPath = normalizePathname(apiUrl.pathname);
79
+ const requestPath = normalizePathname(requestUrl.pathname);
80
+
81
+ return requestUrl.origin !== apiUrl.origin || !requestPath.startsWith(apiPath);
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
61
87
  // Helper: detect a RecordRef-like object
62
88
  function isRecordRefLike(val: any): boolean {
63
89
  return !!(val && typeof val === 'object' && 'collection' in val && 'filterByTk' in val);
@@ -3024,6 +3050,10 @@ class BaseFlowEngineContext extends FlowContext {
3024
3050
  return this.engine.getModel(modelName, searchInPreviousEngines);
3025
3051
  });
3026
3052
  this.defineMethod('request', (options: RequestOptions) => {
3053
+ const app = this.app as { getApiUrl?: (pathname?: string) => string } | undefined;
3054
+ if (typeof options?.url === 'string' && shouldBypassApiClient(options.url, app)) {
3055
+ return axios.request(options);
3056
+ }
3027
3057
  return this.api.request(options);
3028
3058
  });
3029
3059
  this.defineMethod(
package/src/flowEngine.ts CHANGED
@@ -27,7 +27,7 @@ import type {
27
27
  EnsureBatchResult,
28
28
  EventDefinition,
29
29
  FlowModelLoaderEntry,
30
- FlowModelLoaderMap,
30
+ FlowModelLoaderInputMap,
31
31
  FlowModelLoaderResult,
32
32
  FlowModelOptions,
33
33
  IFlowModelRepository,
@@ -483,21 +483,31 @@ export class FlowEngine {
483
483
 
484
484
  /**
485
485
  * Register multiple model loader entries.
486
- * @param {FlowModelLoaderMap} loaders Model loader entry map, key is model name, value is the model loader entry
486
+ * The `extends` field declares parent class(es) for async subclass discovery via `getSubclassesOfAsync`.
487
+ * It accepts `string | ModelConstructor | (string | ModelConstructor)[]` and is normalized to `string[]` internally.
488
+ * @param {FlowModelLoaderInputMap} loaders Model loader input map
487
489
  * @returns {void}
488
490
  * @example
489
491
  * flowEngine.registerModelLoaders({
490
492
  * DemoModel: {
493
+ * extends: 'BaseModel',
491
494
  * loader: () => import('./models/DemoModel'),
492
495
  * },
493
496
  * });
494
497
  */
495
- public registerModelLoaders(loaders: FlowModelLoaderMap): void {
498
+ public registerModelLoaders(loaders: FlowModelLoaderInputMap): void {
496
499
  let changed = false;
497
- for (const [name, entry] of Object.entries(loaders)) {
500
+ for (const [name, input] of Object.entries(loaders)) {
498
501
  if (this._modelLoaders.has(name)) {
499
502
  console.warn(`FlowEngine: Model loader with name '${name}' is already registered and will be overwritten.`);
500
503
  }
504
+ const entry: FlowModelLoaderEntry = {
505
+ loader: input.loader,
506
+ };
507
+ if (input.extends != null) {
508
+ const raw = Array.isArray(input.extends) ? input.extends : [input.extends];
509
+ entry.extends = raw.map((item) => (typeof item === 'string' ? item : item.name));
510
+ }
501
511
  this._modelLoaders.set(name, entry);
502
512
  changed = true;
503
513
  }
@@ -845,6 +855,70 @@ export class FlowEngine {
845
855
  return result;
846
856
  }
847
857
 
858
+ /**
859
+ * Asynchronously get all subclasses of a base class, including those registered via model loaders.
860
+ * Merges results from already-loaded classes (_modelClasses) and async loader entries with matching `extends` declarations.
861
+ * Loader-resolved classes are validated with `isInheritedFrom`; mismatches are warned and excluded.
862
+ * @param {string | ModelConstructor} baseClass Base class name or constructor
863
+ * @param {(ModelClass: ModelConstructor, className: string) => boolean} [filter] Optional filter function
864
+ * @returns {Promise<Map<string, ModelConstructor>>} Model classes that are subclasses of the base class
865
+ */
866
+ public async getSubclassesOfAsync(
867
+ baseClass: string | ModelConstructor,
868
+ filter?: (ModelClass: ModelConstructor, className: string) => boolean,
869
+ ): Promise<Map<string, ModelConstructor>> {
870
+ const baseClassName = typeof baseClass === 'string' ? baseClass : baseClass.name;
871
+
872
+ // If baseClass is a string and not yet loaded, try to resolve it first
873
+ let parentModelClass: ModelConstructor | undefined;
874
+ if (typeof baseClass === 'string') {
875
+ if (!this.getModelClass(baseClass)) {
876
+ await this.ensureModel(baseClass);
877
+ }
878
+ parentModelClass = this.getModelClass(baseClass);
879
+ } else {
880
+ parentModelClass = baseClass;
881
+ }
882
+
883
+ if (!parentModelClass) {
884
+ return new Map();
885
+ }
886
+
887
+ // Step 1: Collect already-loaded subclasses from _modelClasses
888
+ const result = this.getSubclassesOf(parentModelClass, filter);
889
+
890
+ // Step 2: Find unloaded loaders whose extends includes baseClassName
891
+ const loaderCandidates: string[] = [];
892
+ for (const [name, entry] of this._modelLoaders) {
893
+ if (result.has(name) || this._modelClasses.has(name)) continue;
894
+ if (entry.extends?.includes(baseClassName)) {
895
+ loaderCandidates.push(name);
896
+ }
897
+ }
898
+
899
+ // Step 3: Resolve all matching loaders
900
+ if (loaderCandidates.length > 0) {
901
+ await this.ensureModels(loaderCandidates);
902
+ }
903
+
904
+ // Step 4: Validate resolved classes and add to result
905
+ for (const name of loaderCandidates) {
906
+ const ModelClass = this._modelClasses.get(name);
907
+ if (!ModelClass) continue;
908
+ if (!isInheritedFrom(ModelClass, parentModelClass)) {
909
+ console.warn(
910
+ `FlowEngine: Model '${name}' declares extends '${baseClassName}' but does not actually inherit from it. Skipping.`,
911
+ );
912
+ continue;
913
+ }
914
+ if (!filter || filter(ModelClass, name)) {
915
+ result.set(name, ModelClass);
916
+ }
917
+ }
918
+
919
+ return result;
920
+ }
921
+
848
922
  /**
849
923
  * Create and register a model instance.
850
924
  * If an instance with the same UID exists, returns the existing instance.
@@ -34,6 +34,7 @@
34
34
  "Failed to destroy model after creation error": "Failed to destroy model after creation error",
35
35
  "Failed to get action {{action}}": "Failed to get action {{action}}",
36
36
  "Failed to get configurable flows for model {{model}}": "Failed to get configurable flows for model {{model}}",
37
+ "Attributes are unavailable before selecting a record": "Attributes are unavailable before selecting a record",
37
38
  "Failed to import FormDialog": "Failed to import FormDialog",
38
39
  "Failed to import FormDialog or FormStep": "Failed to import FormDialog or FormStep",
39
40
  "Failed to import Formily components": "Failed to import Formily components",
@@ -31,6 +31,7 @@
31
31
  "Failed to destroy model after creation error": "创建错误后销毁模型失败",
32
32
  "Failed to get action {{action}}": "获取 action '{{action}}' 失败",
33
33
  "Failed to get configurable flows for model {{model}}": "获取模型 '{{model}}' 的可配置 flows 失败",
34
+ "Attributes are unavailable before selecting a record": "选择记录之前,当前项属性不可用",
34
35
  "Failed to import FormDialog": "导入 FormDialog 失败",
35
36
  "Failed to import FormDialog or FormStep": "导入 FormDialog 或 FormStep 失败",
36
37
  "Failed to import Formily components": "导入 Formily 组件失败",
@@ -208,4 +208,86 @@ describe('observer', () => {
208
208
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
209
209
  expect(screen.queryByText('Count: 1')).not.toBeInTheDocument();
210
210
  });
211
+
212
+ it('should flush pending update without TDZ error when context becomes active before timer callback runs', async () => {
213
+ vi.useFakeTimers();
214
+
215
+ try {
216
+ const model = observable({ count: 0 });
217
+ const pageActive = observable.ref(false);
218
+ const tabActive = observable.ref(true);
219
+
220
+ const context = {
221
+ pageActive,
222
+ tabActive,
223
+ };
224
+
225
+ (useFlowContext as any).mockReturnValue(context);
226
+
227
+ const Component = observer(() => <div>Count: {model.count}</div>);
228
+
229
+ render(<Component />);
230
+
231
+ expect(screen.getByText('Count: 0')).toBeInTheDocument();
232
+
233
+ act(() => {
234
+ model.count++;
235
+ pageActive.value = true;
236
+ });
237
+
238
+ await act(async () => {
239
+ await vi.runAllTimersAsync();
240
+ });
241
+
242
+ expect(screen.getByText('Count: 1')).toBeInTheDocument();
243
+ } finally {
244
+ vi.useRealTimers();
245
+ }
246
+ });
247
+
248
+ it('should cleanup pending timer and listener on unmount', async () => {
249
+ vi.useFakeTimers();
250
+
251
+ try {
252
+ const model = observable({ count: 0 });
253
+ const pageActive = observable.ref(false);
254
+ const tabActive = observable.ref(true);
255
+ const renderSpy = vi.fn();
256
+
257
+ const context = {
258
+ pageActive,
259
+ tabActive,
260
+ };
261
+
262
+ (useFlowContext as any).mockReturnValue(context);
263
+
264
+ const Component = observer(() => {
265
+ renderSpy(model.count);
266
+ return <div>Count: {model.count}</div>;
267
+ });
268
+
269
+ const { unmount } = render(<Component />);
270
+
271
+ expect(renderSpy).toHaveBeenCalledTimes(1);
272
+ expect(screen.getByText('Count: 0')).toBeInTheDocument();
273
+
274
+ act(() => {
275
+ model.count++;
276
+ });
277
+
278
+ unmount();
279
+
280
+ act(() => {
281
+ pageActive.value = true;
282
+ });
283
+
284
+ await act(async () => {
285
+ await vi.runAllTimersAsync();
286
+ });
287
+
288
+ expect(renderSpy).toHaveBeenCalledTimes(1);
289
+ } finally {
290
+ vi.useRealTimers();
291
+ }
292
+ });
211
293
  });
@@ -10,7 +10,7 @@
10
10
  import { observer as originalObserver, IObserverOptions, ReactFC } from '@formily/reactive-react';
11
11
  import React, { useMemo, useEffect, useRef } from 'react';
12
12
  import { useFlowContext } from '../FlowContextProvider';
13
- import { autorun } from '@formily/reactive';
13
+ import { reaction } from '@formily/reactive';
14
14
  import { FlowEngineContext } from '..';
15
15
 
16
16
  type ObserverComponentProps<P, Options extends IObserverOptions> = Options extends {
@@ -30,12 +30,67 @@ export const observer = <P, Options extends IObserverOptions = IObserverOptions>
30
30
  const ctxRef = useRef(ctx);
31
31
  ctxRef.current = ctx;
32
32
 
33
- // Store the pending disposer to avoid creating multiple listeners
33
+ // 保存延迟更新的监听器,避免重复创建监听。
34
34
  const pendingDisposerRef = useRef<(() => void) | null>(null);
35
+ // 保存延迟创建监听器的定时器,避免组件卸载后仍继续调度。
36
+ const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
35
37
 
36
- // Cleanup on unmount
38
+ /**
39
+ * 清理挂起的可见性监听器。
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * clearPendingDisposer();
44
+ * ```
45
+ */
46
+ const clearPendingDisposer = () => {
47
+ if (pendingDisposerRef.current) {
48
+ pendingDisposerRef.current();
49
+ pendingDisposerRef.current = null;
50
+ }
51
+ };
52
+
53
+ /**
54
+ * 清理挂起的定时器。
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * clearPendingTimer();
59
+ * ```
60
+ */
61
+ const clearPendingTimer = () => {
62
+ if (pendingTimerRef.current) {
63
+ clearTimeout(pendingTimerRef.current);
64
+ pendingTimerRef.current = null;
65
+ }
66
+ };
67
+
68
+ /**
69
+ * 判断当前页面与标签页是否允许立即更新。
70
+ *
71
+ * @returns 当前上下文是否处于可更新状态。
72
+ * @example
73
+ * ```typescript
74
+ * if (isContextActive()) {
75
+ * updater();
76
+ * }
77
+ * ```
78
+ */
79
+ const isContextActive = () => {
80
+ const pageActive = getPageActive(ctxRef.current);
81
+ const tabActive = ctxRef.current?.tabActive?.value;
82
+
83
+ return pageActive !== false && tabActive !== false;
84
+ };
85
+
86
+ // 组件卸载时统一清理所有挂起任务,避免异步回调在卸载后继续运行。
37
87
  useEffect(() => {
38
88
  return () => {
89
+ if (pendingTimerRef.current) {
90
+ clearTimeout(pendingTimerRef.current);
91
+ pendingTimerRef.current = null;
92
+ }
93
+
39
94
  if (pendingDisposerRef.current) {
40
95
  pendingDisposerRef.current();
41
96
  pendingDisposerRef.current = null;
@@ -47,38 +102,45 @@ export const observer = <P, Options extends IObserverOptions = IObserverOptions>
47
102
  () =>
48
103
  originalObserver(Component, {
49
104
  scheduler(updater) {
50
- const pageActive = getPageActive(ctxRef.current);
51
- const tabActive = ctxRef.current?.tabActive?.value;
105
+ if (!isContextActive()) {
106
+ if (pendingTimerRef.current || pendingDisposerRef.current) {
107
+ return;
108
+ }
109
+
110
+ // 通过异步任务打断同步调度,避免连续触发时形成递归更新。
111
+ pendingTimerRef.current = setTimeout(() => {
112
+ pendingTimerRef.current = null;
52
113
 
53
- if (pageActive === false || tabActive === false) {
54
- // Avoid stack overflow
55
- setTimeout(() => {
56
- // If there is already a pending updater, do nothing
57
114
  if (pendingDisposerRef.current) {
58
115
  return;
59
116
  }
60
117
 
61
- // Delay the update until the page and tab become active
62
- const disposer = autorun(() => {
63
- if (
64
- ctxRef.current?.pageActive?.value &&
65
- (ctxRef.current?.tabActive?.value === true || ctxRef.current?.tabActive?.value === undefined)
66
- ) {
118
+ if (isContextActive()) {
119
+ updater();
120
+ return;
121
+ }
122
+
123
+ // 只监听组合后的“是否可更新”状态,条件恢复后执行一次并立即销毁。
124
+ pendingDisposerRef.current = reaction(
125
+ () => isContextActive(),
126
+ (active) => {
127
+ if (!active) {
128
+ return;
129
+ }
130
+
131
+ clearPendingDisposer();
67
132
  updater();
68
- disposer?.();
69
- pendingDisposerRef.current = null;
70
- }
71
- });
72
- pendingDisposerRef.current = disposer;
133
+ },
134
+ {
135
+ name: 'FlowObserverPendingUpdate',
136
+ },
137
+ );
73
138
  });
74
139
  return;
75
140
  }
76
141
 
77
- // If we are updating immediately, clear any pending disposer
78
- if (pendingDisposerRef.current) {
79
- pendingDisposerRef.current();
80
- pendingDisposerRef.current = null;
81
- }
142
+ clearPendingTimer();
143
+ clearPendingDisposer();
82
144
 
83
145
  updater();
84
146
  },
package/src/types.ts CHANGED
@@ -407,22 +407,34 @@ export type FlowModelLoaderResult =
407
407
  export type FlowModelLoader = () => Promise<FlowModelLoaderResult>;
408
408
 
409
409
  /**
410
- * FlowModel loader entry.
411
- * Future contribution-style fields are intentionally kept as commented placeholders in phase A
412
- * and are not consumed by current runtime logic.
410
+ * FlowModel loader entry (normalized internal form).
413
411
  */
414
412
  export interface FlowModelLoaderEntry {
415
413
  loader: FlowModelLoader;
416
- // extends?: string;
414
+ extends?: string[];
417
415
  // meta?: Partial<FlowModelMeta>;
418
416
  // scenes?: string[];
419
417
  }
420
418
 
421
419
  /**
422
- * FlowModel loader entry map.
420
+ * FlowModel loader input (user-facing form for registerModelLoaders).
421
+ * The `extends` field accepts flexible formats that will be normalized to `string[]` at registration time.
422
+ */
423
+ export interface FlowModelLoaderInput {
424
+ loader: FlowModelLoader;
425
+ extends?: string | ModelConstructor | (string | ModelConstructor)[];
426
+ }
427
+
428
+ /**
429
+ * FlowModel loader entry map (normalized internal form).
423
430
  */
424
431
  export type FlowModelLoaderMap = Record<string, FlowModelLoaderEntry>;
425
432
 
433
+ /**
434
+ * FlowModel loader input map (user-facing form for registerModelLoaders).
435
+ */
436
+ export type FlowModelLoaderInputMap = Record<string, FlowModelLoaderInput>;
437
+
426
438
  /**
427
439
  * Batch ensure result.
428
440
  */