@nocobase/client-v2 2.1.0-beta.24 → 2.1.0-beta.25

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 (27) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/flow/actions/dataScopeFilter.d.ts +9 -0
  3. package/es/flow/internal/utils/rebuildFieldSubModel.d.ts +2 -1
  4. package/es/flow/models/fields/JSFieldModel.d.ts +5 -0
  5. package/es/index.mjs +100 -100
  6. package/lib/index.js +89 -89
  7. package/package.json +6 -5
  8. package/src/BaseApplication.tsx +4 -0
  9. package/src/__tests__/globalDeps.test.ts +1 -0
  10. package/src/__tests__/remotePlugins.test.ts +27 -0
  11. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +158 -0
  12. package/src/flow/actions/dataScope.tsx +6 -4
  13. package/src/flow/actions/dataScopeFilter.ts +70 -0
  14. package/src/flow/actions/setTargetDataScope.tsx +6 -5
  15. package/src/flow/internal/utils/__tests__/rebuildFieldSubModel.test.ts +77 -2
  16. package/src/flow/internal/utils/rebuildFieldSubModel.ts +21 -5
  17. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +9 -5
  18. package/src/flow/models/blocks/filter-form/__tests__/FilterFormBlockModel.cleanup.test.ts +138 -0
  19. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +22 -0
  20. package/src/flow/models/blocks/table/JSColumnModel.tsx +30 -2
  21. package/src/flow/models/blocks/table/TableBlockModel.tsx +8 -1
  22. package/src/flow/models/blocks/table/TableColumnModel.tsx +1 -0
  23. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +51 -0
  24. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +49 -0
  25. package/src/flow/models/fields/JSFieldModel.tsx +54 -14
  26. package/src/utils/globalDeps.ts +4 -0
  27. package/src/utils/requirejs.ts +1 -1
@@ -0,0 +1,138 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import '@nocobase/client';
12
+ import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
13
+ import { waitFor } from '@testing-library/react';
14
+ import { TableBlockModel } from '../../table/TableBlockModel';
15
+ import { FilterFormBlockModel } from '../FilterFormBlockModel';
16
+ import { FilterFormGridModel } from '../FilterFormGridModel';
17
+ import { FilterFormItemModel } from '../FilterFormItemModel';
18
+
19
+ describe('FilterFormBlockModel cleanup', () => {
20
+ function createFilterFormSetup() {
21
+ const engine = new FlowEngine();
22
+ engine.registerModels({
23
+ FlowModel,
24
+ TableBlockModel,
25
+ FilterFormBlockModel,
26
+ FilterFormGridModel,
27
+ FilterFormItemModel,
28
+ });
29
+
30
+ const blockGridModel = engine.createModel<FlowModel>({
31
+ uid: 'block-grid',
32
+ use: 'FlowModel',
33
+ subModels: {
34
+ items: [],
35
+ },
36
+ } as any);
37
+
38
+ const tableBlock = blockGridModel.addSubModel('items', {
39
+ uid: 'target-table',
40
+ use: 'TableBlockModel',
41
+ }) as TableBlockModel;
42
+
43
+ const filterForm = blockGridModel.addSubModel('items', {
44
+ uid: 'filter-form',
45
+ use: 'FilterFormBlockModel',
46
+ subModels: {
47
+ grid: {
48
+ uid: 'filter-grid',
49
+ use: 'FilterFormGridModel',
50
+ subModels: {
51
+ items: [],
52
+ },
53
+ },
54
+ },
55
+ }) as FilterFormBlockModel;
56
+
57
+ const filterItem = filterForm.subModels.grid.addSubModel('items', {
58
+ uid: 'filter-item',
59
+ use: 'FilterFormItemModel',
60
+ stepParams: {
61
+ filterFormItemSettings: {
62
+ init: {
63
+ defaultTargetUid: tableBlock.uid,
64
+ },
65
+ },
66
+ },
67
+ }) as FilterFormItemModel;
68
+
69
+ const removeFilterConfig = vi.fn(async () => {});
70
+ const saveConnectFieldsConfig = vi.fn(async () => {});
71
+ const getConnectFieldsConfig = vi.fn(() => ({
72
+ targets: [
73
+ {
74
+ targetId: tableBlock.uid,
75
+ filterPaths: ['name'],
76
+ },
77
+ ],
78
+ }));
79
+
80
+ filterForm.context.defineProperty('blockGridModel', { value: blockGridModel });
81
+ filterForm.context.defineProperty('filterManager', {
82
+ value: {
83
+ removeFilterConfig,
84
+ saveConnectFieldsConfig,
85
+ getConnectFieldsConfig,
86
+ },
87
+ });
88
+ filterForm.subModels.grid.context.defineProperty('filterManager', {
89
+ value: {
90
+ removeFilterConfig,
91
+ saveConnectFieldsConfig,
92
+ getConnectFieldsConfig,
93
+ },
94
+ });
95
+
96
+ const destroySpy = vi.spyOn(filterItem, 'destroy').mockResolvedValue(true as any);
97
+ vi.spyOn(filterForm as any, 'applyDefaultsAndInitialFilter').mockResolvedValue(undefined);
98
+
99
+ (filterForm as any).onMount();
100
+
101
+ return {
102
+ engine,
103
+ blockGridModel,
104
+ tableBlock,
105
+ filterForm,
106
+ filterItem,
107
+ removeFilterConfig,
108
+ saveConnectFieldsConfig,
109
+ getConnectFieldsConfig,
110
+ destroySpy,
111
+ };
112
+ }
113
+
114
+ it('does not remove filter items when target block is only removed during popup teardown', async () => {
115
+ const { engine, blockGridModel, tableBlock, filterForm, removeFilterConfig, destroySpy } = createFilterFormSetup();
116
+
117
+ await Promise.resolve(engine.removeModelWithSubModels(blockGridModel.uid));
118
+
119
+ expect(removeFilterConfig).not.toHaveBeenCalled();
120
+ expect(destroySpy).not.toHaveBeenCalled();
121
+
122
+ (filterForm as any).onUnmount();
123
+ expect(engine.getModel(tableBlock.uid)).toBeUndefined();
124
+ });
125
+
126
+ it('removes filter items when target block is actually destroyed', async () => {
127
+ const { tableBlock, filterForm, removeFilterConfig, destroySpy } = createFilterFormSetup();
128
+
129
+ await tableBlock.destroy();
130
+
131
+ await waitFor(() => {
132
+ expect(removeFilterConfig).toHaveBeenCalledWith({ filterId: 'filter-item' });
133
+ expect(destroySpy).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ (filterForm as any).onUnmount();
137
+ });
138
+ });
@@ -85,6 +85,7 @@ async function setupFormModel() {
85
85
  { name: 'assignees', type: 'belongsToMany', target: 'users', interface: 'm2m' },
86
86
  { name: 'note', type: 'string', interface: 'text' },
87
87
  { name: 'status', type: 'string', interface: 'text' },
88
+ { name: 'rawPayload', type: 'json', filterable: true },
88
89
  ],
89
90
  });
90
91
 
@@ -283,6 +284,27 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
283
284
  expect(params.note).toBeUndefined();
284
285
  });
285
286
 
287
+ it('keeps interfaced fields in formValues meta even when they are not configured in the form grid', async () => {
288
+ const model = await setupFormModel();
289
+
290
+ function HookCaller() {
291
+ model.useHooksBeforeRender();
292
+ return null;
293
+ }
294
+ render(React.createElement(HookCaller));
295
+ mockFormGridEnabledFields(model, ['customer', 'note']);
296
+
297
+ const opt = (model.context as any).getPropertyOptions('formValues');
298
+ const meta = await opt.meta();
299
+ const props = await meta.properties();
300
+
301
+ expect(props).toHaveProperty('customer');
302
+ expect(props).toHaveProperty('note');
303
+ expect(props).toHaveProperty('status');
304
+ expect(props).toHaveProperty('assignees');
305
+ expect(props).not.toHaveProperty('rawPayload');
306
+ });
307
+
286
308
  it('registers formValuesChange event and eventSettings flow', async () => {
287
309
  const engine = new FlowEngine();
288
310
  const TestFormModel = await createTestFormModelSubclass();
@@ -30,6 +30,27 @@ import { TableCustomColumnModel } from './TableCustomColumnModel';
30
30
  import { CodeEditor } from '../../../components/code-editor';
31
31
  import { resolveRunJsParams } from '../../utils/resolveRunJsParams';
32
32
 
33
+ function getRecordRenderSignature(record: any) {
34
+ if (!record || typeof record !== 'object') {
35
+ return String(record);
36
+ }
37
+
38
+ try {
39
+ const seen = new WeakSet();
40
+ return JSON.stringify(record, (_key, value) => {
41
+ if (value && typeof value === 'object') {
42
+ if (seen.has(value)) {
43
+ return '[Circular]';
44
+ }
45
+ seen.add(value);
46
+ }
47
+ return value;
48
+ });
49
+ } catch (error) {
50
+ return String(record);
51
+ }
52
+ }
53
+
33
54
  export class JSColumnModel extends TableCustomColumnModel {
34
55
  // Stable per‑instance render component to avoid remounts across re-renders
35
56
  private _RenderComponent?: React.ComponentType;
@@ -113,7 +134,13 @@ export class JSColumnModel extends TableCustomColumnModel {
113
134
  // 使用记录主键作为 fork key,避免分页后 index 复用导致 fork 复用
114
135
  const tk = this.context.collection?.getFilterByTK?.(record);
115
136
  const forkKey = tk ?? record?.id ?? index;
137
+ const recordSignature = getRecordRenderSignature(record);
116
138
  const fork = this.createFork({}, String(forkKey));
139
+ const previousRecordSignature = (fork as any).__recordRenderSignature;
140
+ if (previousRecordSignature !== recordSignature) {
141
+ (fork as any).__recordRenderSignature = recordSignature;
142
+ fork.invalidateFlowCache('beforeRender');
143
+ }
117
144
  const recordMeta: PropertyMetaFactory = createRecordMetaFactory(
118
145
  () => fork.context.collection,
119
146
  fork.context.t('Current record'),
@@ -137,7 +164,7 @@ export class JSColumnModel extends TableCustomColumnModel {
137
164
  fork.context.defineProperty('recordIndex', {
138
165
  get: () => index,
139
166
  });
140
- return <MemoFlowModelRenderer key={fork.uid} model={fork} />;
167
+ return <MemoFlowModelRenderer key={`${fork.uid}:${recordSignature}`} model={fork} />;
141
168
  },
142
169
  };
143
170
  }
@@ -264,7 +291,8 @@ JSColumnModel.registerFlow({
264
291
 
265
292
  ctx.onRefReady(ctx.ref, async (element) => {
266
293
  ctx.defineProperty('element', {
267
- get: () => new ElementProxy(element),
294
+ get: () => new ElementProxy((ctx.ref?.current as HTMLElement | null) || element),
295
+ cache: false,
268
296
  });
269
297
  const navigator = createSafeNavigator();
270
298
  await ctx.runjs(
@@ -314,7 +314,14 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
314
314
  onSuccess: (values) => {
315
315
  const collectionField = this.collection.getField(dataIndex);
316
316
  record[dataIndex] = values[dataIndex];
317
- setNestedValue(this.resource.getData(), recordIndex, record);
317
+ if (typeof recordIndex === 'number') {
318
+ this.resource.setItem(recordIndex, record);
319
+ } else {
320
+ const nextData = _.cloneDeep(this.resource.getData());
321
+ setNestedValue(nextData, recordIndex, record);
322
+ this.resource.setData(nextData);
323
+ }
324
+ this.resource.emit('refresh');
318
325
  // 仅重渲染单元格
319
326
  const fork: ForkFlowModel = model.subModels.field.createFork({}, `${recordIndex}`);
320
327
  // Provide expandable meta for current row record based on the collection in context
@@ -313,6 +313,7 @@ export class TableColumnModel extends DisplayItemModel {
313
313
  ? this.fieldPath.replace(`${this.context.prefixFieldPath}.`, '')
314
314
  : this.fieldPath;
315
315
  const value = get(record, namePath);
316
+ fork.setProps({ value });
316
317
  return (
317
318
  <FormItem key={field.uid} {...omit(this.props, 'title')} value={value} noStyle={true}>
318
319
  <FieldModelRenderer model={fork} />
@@ -0,0 +1,51 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { FlowEngine } from '@nocobase/flow-engine';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { JSColumnModel } from '../JSColumnModel';
13
+
14
+ describe('JSColumnModel row refresh', () => {
15
+ it('changes renderer key and invalidates beforeRender cache when row content changes', () => {
16
+ const engine = new FlowEngine();
17
+ const model = new JSColumnModel({
18
+ uid: 'js-column-row-refresh',
19
+ flowEngine: engine,
20
+ props: {
21
+ width: 200,
22
+ title: 'JS column',
23
+ },
24
+ } as any);
25
+
26
+ engine.context.dataSourceManager.getDataSource('main').addCollection({
27
+ name: 'users',
28
+ filterTargetKey: 'id',
29
+ fields: [
30
+ { name: 'id', type: 'integer', interface: 'number' },
31
+ { name: 'age', type: 'integer', interface: 'integer' },
32
+ { name: 'workyears', type: 'float', interface: 'number' },
33
+ ],
34
+ });
35
+
36
+ const collection = engine.context.dataSourceManager.getCollection('main', 'users');
37
+ model.context.defineProperty('collection', {
38
+ value: collection,
39
+ });
40
+
41
+ const column = model.getColumnProps();
42
+ const first = column.render(null, { id: 1, age: 3, workyears: 39.2 }, 0) as any;
43
+ const fork = model.getFork('1') as any;
44
+ const invalidateFlowCache = vi.fn();
45
+ fork.invalidateFlowCache = invalidateFlowCache;
46
+ const second = column.render(null, { id: 1, age: 37, workyears: 39.2 }, 0) as any;
47
+
48
+ expect(first.key).not.toBe(second.key);
49
+ expect(invalidateFlowCache).toHaveBeenCalledWith('beforeRender');
50
+ });
51
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ describe('TableBlockModel quick edit refresh', () => {
14
+ it('updates table data through a new array reference after quick editing a flat row', () => {
15
+ const engine = new FlowEngine();
16
+ const resource = engine.context.createResource(MultiRecordResource);
17
+ const original = [
18
+ { id: 1, title: 'old title', status: 'draft' },
19
+ { id: 2, title: 'other title', status: 'published' },
20
+ ];
21
+
22
+ resource.setData(original);
23
+ const before = resource.getData();
24
+ const editedRecord = { ...before[0], title: 'new title' };
25
+
26
+ resource.setItem(0, editedRecord);
27
+
28
+ expect(resource.getData()).not.toBe(before);
29
+ expect(resource.getData()[0]).toEqual(editedRecord);
30
+ expect(resource.getData()[1]).toEqual(original[1]);
31
+ });
32
+
33
+ it('notifies mounted JS fields after quick editing local table data', () => {
34
+ const engine = new FlowEngine();
35
+ const resource = engine.context.createResource(MultiRecordResource);
36
+ const original = [{ id: 1, title: 'old title', status: 'draft' }];
37
+ let refreshCount = 0;
38
+ resource.on('refresh', () => {
39
+ refreshCount += 1;
40
+ });
41
+
42
+ resource.setData(original);
43
+ const editedRecord = { ...resource.getData()[0], title: 'new title' };
44
+ resource.setItem(0, editedRecord);
45
+ resource.emit('refresh');
46
+
47
+ expect(refreshCount).toBe(1);
48
+ });
49
+ });
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { ElementProxy, tExpr, createSafeWindow, createSafeDocument, createSafeNavigator } from '@nocobase/flow-engine';
11
- import React, { useEffect, useRef } from 'react';
11
+ import React, { useEffect } from 'react';
12
12
  import { FieldModel } from '../base/FieldModel';
13
13
  import { resolveRunJsParams } from '../utils/resolveRunJsParams';
14
14
  import { CodeEditor } from '../../components/code-editor';
@@ -41,6 +41,10 @@ ctx.render(<JsReadonlyField />);
41
41
  */
42
42
  export class JSFieldModel extends FieldModel {
43
43
  private _mountedOnce = false; // prevent first-mount double-run
44
+ private _lastRenderedElement?: HTMLSpanElement | null;
45
+ private _pendingRenderedElement?: HTMLSpanElement | null;
46
+ private _lastRunJs?: { code: string; value: any; element: HTMLSpanElement | null };
47
+
44
48
  getInputArgs() {
45
49
  const field = this.context.collectionField;
46
50
  if (field?.isAssociationField?.()) {
@@ -75,19 +79,10 @@ export class JSFieldModel extends FieldModel {
75
79
  * 说明:fork 实例在表格逐行渲染时会复用该逻辑,确保按值更新。
76
80
  */
77
81
  useHooksBeforeRender() {
78
- // 单一副作用:当 code 或 value 变化,且二者都已就绪时执行一次
79
- // 通过记忆上次运行的输入,避免相同输入导致的重复执行
80
82
  const codeParam = this.getStepParams('jsSettings', 'runJs')?.code as string | undefined;
81
83
  // eslint-disable-next-line react-hooks/rules-of-hooks
82
- const lastRunRef = useRef<{ code: string; value: any } | null>(null);
83
- // eslint-disable-next-line react-hooks/rules-of-hooks
84
84
  useEffect(() => {
85
- const valueNow = this.props.value;
86
- const codeNow = (typeof codeParam === 'string' && codeParam.trim().length ? codeParam : DEFAULT_CODE).trim();
87
- const last = lastRunRef.current;
88
- if (last && last.code === codeNow && last.value === valueNow) return;
89
- lastRunRef.current = { code: codeNow, value: valueNow };
90
- this.applyFlow('jsSettings');
85
+ this.refreshRenderedElement(this.context.ref?.current as HTMLSpanElement | null);
91
86
  // eslint-disable-next-line react-hooks/exhaustive-deps
92
87
  }, [codeParam, this.props.value]);
93
88
  }
@@ -95,8 +90,52 @@ export class JSFieldModel extends FieldModel {
95
90
  /**
96
91
  * 渲染一个占位容器,供 JS 脚本写入内容
97
92
  */
93
+ private getRunJsCode() {
94
+ const codeParam = this.getStepParams('jsSettings', 'runJs')?.code as string | undefined;
95
+ return (typeof codeParam === 'string' && codeParam.trim().length ? codeParam : DEFAULT_CODE).trim();
96
+ }
97
+
98
+ private refreshRenderedElement(element: HTMLSpanElement | null) {
99
+ if (!element || this._pendingRenderedElement === element) {
100
+ return;
101
+ }
102
+
103
+ this._pendingRenderedElement = element;
104
+ const ref = this.context.ref as React.MutableRefObject<HTMLSpanElement | null>;
105
+
106
+ queueMicrotask(() => {
107
+ if (this._pendingRenderedElement === element) {
108
+ this._pendingRenderedElement = null;
109
+ }
110
+ if (ref.current !== element) {
111
+ return;
112
+ }
113
+ const code = this.getRunJsCode();
114
+ const value = this.props.value;
115
+ const last = this._lastRunJs;
116
+ if (last && last.element === element && last.code === code && Object.is(last.value, value)) {
117
+ return;
118
+ }
119
+ this._lastRunJs = { code, value, element };
120
+ void this.applyFlow('jsSettings');
121
+ });
122
+ }
123
+
98
124
  render() {
99
- return <span ref={this.context.ref} style={{ display: 'inline-block', maxWidth: '100%' }} />;
125
+ const ref = this.context.ref as React.MutableRefObject<HTMLSpanElement | null>;
126
+ const assignRef = (element: HTMLSpanElement | null) => {
127
+ ref.current = element;
128
+ if (!element) {
129
+ return;
130
+ }
131
+
132
+ const elementChanged = this._lastRenderedElement && this._lastRenderedElement !== element;
133
+ this._lastRenderedElement = element;
134
+ if (elementChanged || !this._mountedOnce) {
135
+ this.refreshRenderedElement(element);
136
+ }
137
+ };
138
+ return <span ref={assignRef} style={{ display: 'inline-block', maxWidth: '100%' }} />;
100
139
  }
101
140
 
102
141
  /**
@@ -106,7 +145,7 @@ export class JSFieldModel extends FieldModel {
106
145
  protected onMount() {
107
146
  if (this._mountedOnce) {
108
147
  if (this.context.ref?.current) {
109
- this.rerender();
148
+ this.refreshRenderedElement(this.context.ref.current as HTMLSpanElement);
110
149
  }
111
150
  }
112
151
  this._mountedOnce = true;
@@ -165,7 +204,8 @@ JSFieldModel.registerFlow({
165
204
  // 暴露 element 与 value 到运行上下文
166
205
  ctx.onRefReady(ctx.ref, async (element) => {
167
206
  ctx.defineProperty('element', {
168
- get: () => new ElementProxy(element),
207
+ get: () => new ElementProxy((ctx.ref?.current as HTMLElement | null) || element),
208
+ cache: false,
169
209
  });
170
210
  ctx.defineProperty('value', {
171
211
  get: () => ctx.model.props?.value,
@@ -15,6 +15,7 @@ import * as formilyReactive from '@formily/reactive';
15
15
  import * as formilyShared from '@formily/shared';
16
16
  import * as nocobaseClientUtils from '@nocobase/utils/client';
17
17
  import * as nocobaseFlowEngine from '@nocobase/flow-engine';
18
+ import * as ahooks from 'ahooks';
18
19
  import * as antd from 'antd';
19
20
  import * as i18next from 'i18next';
20
21
  import React from 'react';
@@ -61,4 +62,7 @@ export function defineGlobalDeps(requirejs: RequireJS) {
61
62
  requirejs.define('@nocobase/client-v2', () => nocobaseClientV2);
62
63
  requirejs.define('@nocobase/client-v2/client-v2', () => nocobaseClientV2);
63
64
  requirejs.define('@nocobase/flow-engine', () => nocobaseFlowEngine);
65
+
66
+ // utils
67
+ requirejs.define('ahooks', () => ahooks);
64
68
  }
@@ -1686,7 +1686,7 @@ export function getRequireJs(): RequireJS {
1686
1686
 
1687
1687
  //Join the path parts together, then figure out if baseUrl is needed.
1688
1688
  url = syms.join('/');
1689
- url += (ext || (/^data\:|^blob\:|\?/.test(url) || skipExt ? '' : '.js'));
1689
+ url += (ext || (/^data\:|^blob\:|\?/.test(url) || /\.js(?:$|[?#])/.test(url) || skipExt ? '' : '.js'));
1690
1690
  url = (url.charAt(0) === '/' || url.match(/^[\w\+\.\-]+:/) ? '' : config.baseUrl) + url;
1691
1691
  }
1692
1692