@nocobase/client-v2 2.2.0-beta.10 → 2.2.0-beta.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.2.0-beta.10",
3
+ "version": "2.2.0-beta.12",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.2.0-beta.10",
31
- "@nocobase/flow-engine": "2.2.0-beta.10",
32
- "@nocobase/sdk": "2.2.0-beta.10",
33
- "@nocobase/shared": "2.2.0-beta.10",
34
- "@nocobase/utils": "2.2.0-beta.10",
30
+ "@nocobase/evaluators": "2.2.0-beta.12",
31
+ "@nocobase/flow-engine": "2.2.0-beta.12",
32
+ "@nocobase/sdk": "2.2.0-beta.12",
33
+ "@nocobase/shared": "2.2.0-beta.12",
34
+ "@nocobase/utils": "2.2.0-beta.12",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -42,9 +42,10 @@
42
42
  "html5-qrcode": "^2.3.8",
43
43
  "i18next": "^22.4.9",
44
44
  "json5": "^2.2.3",
45
+ "jsqr": "^1.4.0",
45
46
  "lodash": "4.17.21",
46
47
  "react-i18next": "^11.15.1",
47
48
  "react-router-dom": "^6.30.1"
48
49
  },
49
- "gitHead": "d572beec24de46df948f28db72b25303a63bf62d"
50
+ "gitHead": "ad8ed32d47eaec2dc56f15999934f720787d5070"
50
51
  }
@@ -35,6 +35,7 @@ import { SystemSettingsSource } from './flow/system-settings';
35
35
  import { LayoutManager } from './layout-manager/LayoutManager';
36
36
  import type { PluginClass, PluginManager, PluginType } from './PluginManager';
37
37
  import { RouteRepository } from './RouteRepository';
38
+ import { stripModernClientPrefix } from './authRedirect';
38
39
  import type {
39
40
  ComponentTypeAndString,
40
41
  RenderableComponentType,
@@ -81,6 +82,10 @@ const trimTrailingSlashes = (value: string) => {
81
82
  return match ? value.slice(0, -match[0].length) : value;
82
83
  };
83
84
 
85
+ const ensureTrailingSlash = (value: string) => {
86
+ return `${trimTrailingSlashes(value)}/`;
87
+ };
88
+
84
89
  const isRenderableComponentType = (value: unknown): value is AnyComponent => isValidElementType(value);
85
90
 
86
91
  export type DevDynamicImport = (packageName: string) => Promise<{ default: PluginClass }>;
@@ -434,15 +439,11 @@ export abstract class BaseApplication<
434
439
  }
435
440
 
436
441
  getCdnUrl() {
437
- return window['__webpack_public_path__'] || this.getPublicPath();
442
+ return ensureTrailingSlash(window['__webpack_public_path__'] || stripModernClientPrefix(this.getPublicPath()));
438
443
  }
439
444
 
440
445
  getPublicPath() {
441
- let publicPath = this.options.publicPath || '/';
442
- if (!publicPath.endsWith('/')) {
443
- publicPath += '/';
444
- }
445
- return publicPath;
446
+ return ensureTrailingSlash(this.options.publicPath || '/');
446
447
  }
447
448
 
448
449
  getApiUrl(pathname = '') {
@@ -34,6 +34,8 @@ describe('app', () => {
34
34
  afterEach(() => {
35
35
  document.querySelectorAll('link[rel="shortcut icon"]').forEach((node) => node.remove());
36
36
  document.documentElement.removeAttribute('lang');
37
+ delete window['__webpack_public_path__'];
38
+ delete window['__nocobase_modern_client_prefix__'];
37
39
  vi.restoreAllMocks();
38
40
  });
39
41
 
@@ -58,6 +60,59 @@ describe('app', () => {
58
60
  expect(app.jsonLogic.apply({ $testAlwaysTrue: [] })).toBe(true);
59
61
  });
60
62
 
63
+ it('should normalize publicPath and webpack public path with a single trailing slash', () => {
64
+ const app = new Application({
65
+ router,
66
+ publicPath: '/admin//',
67
+ });
68
+
69
+ expect(app.getPublicPath()).toBe('/admin/');
70
+
71
+ window['__webpack_public_path__'] = '/cdn/assets///';
72
+ expect(app.getCdnUrl()).toBe('/cdn/assets/');
73
+
74
+ delete window['__webpack_public_path__'];
75
+ expect(app.getCdnUrl()).toBe('/admin/');
76
+ });
77
+
78
+ it('should normalize webpack public path without a trailing slash', () => {
79
+ const app = new Application({
80
+ router,
81
+ publicPath: '/admin/',
82
+ });
83
+
84
+ window['__webpack_public_path__'] = '/cdn/assets';
85
+ expect(app.getCdnUrl()).toBe('/cdn/assets/');
86
+ });
87
+
88
+ it('should remove the modern client prefix from the CDN fallback path', () => {
89
+ const app = new Application({
90
+ router,
91
+ publicPath: '/v/',
92
+ });
93
+
94
+ expect(app.getCdnUrl()).toBe('/');
95
+ });
96
+
97
+ it('should preserve APP_PUBLIC_PATH when removing the modern client prefix', () => {
98
+ const app = new Application({
99
+ router,
100
+ publicPath: '/nocobase/v/',
101
+ });
102
+
103
+ expect(app.getCdnUrl()).toBe('/nocobase/');
104
+ });
105
+
106
+ it('should support a custom modern client prefix', () => {
107
+ window['__nocobase_modern_client_prefix__'] = 'modern';
108
+ const app = new Application({
109
+ router,
110
+ publicPath: '/nocobase/modern/',
111
+ });
112
+
113
+ expect(app.getCdnUrl()).toBe('/nocobase/');
114
+ });
115
+
61
116
  it('should apply the provided favicon immediately', () => {
62
117
  const app = new Application({ router });
63
118
 
@@ -7,9 +7,9 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { render, waitFor } from '@testing-library/react';
10
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
11
11
  import React, { useCallback } from 'react';
12
- import { beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
14
14
 
15
15
  type MockScannerInstance = {
@@ -44,6 +44,12 @@ const mocks = vi.hoisted(() => {
44
44
  };
45
45
  });
46
46
 
47
+ const jsQrMocks = vi.hoisted(() => {
48
+ return {
49
+ default: vi.fn(),
50
+ };
51
+ });
52
+
47
53
  vi.mock('html5-qrcode', () => ({
48
54
  Html5Qrcode: mocks.Html5Qrcode,
49
55
  Html5QrcodeScannerState: {
@@ -66,6 +72,8 @@ vi.mock('html5-qrcode', () => ({
66
72
  },
67
73
  }));
68
74
 
75
+ vi.mock('jsqr', () => jsQrMocks);
76
+
69
77
  function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: number } } = {}) {
70
78
  const handleScanSuccess = useCallback(() => undefined, []);
71
79
 
@@ -79,9 +87,74 @@ function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: n
79
87
  return <div id="scanner" />;
80
88
  }
81
89
 
90
+ function FileScannerHost({ onScanSuccess }: { onScanSuccess: (text: string) => void }) {
91
+ const { startScanFile } = useCodeScanner({
92
+ elementId: 'scanner',
93
+ enabled: true,
94
+ onScanSuccess,
95
+ });
96
+
97
+ return (
98
+ <>
99
+ <div id="scanner" />
100
+ <button onClick={() => startScanFile(new File(['qr'], 'qr.png', { type: 'image/png' }))}>scan file</button>
101
+ </>
102
+ );
103
+ }
104
+
105
+ function stubSafariBrowser() {
106
+ vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.');
107
+ vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
108
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
109
+ );
110
+ }
111
+
112
+ function stubImageElement(size: { width: number; height: number } = { width: 600, height: 400 }) {
113
+ vi.stubGlobal(
114
+ 'Image',
115
+ class MockImage {
116
+ onabort: ((event: Event) => void) | null = null;
117
+ onerror: ((event: Event) => void) | null = null;
118
+ onload: (() => void) | null = null;
119
+ height = size.height;
120
+ naturalHeight = size.height;
121
+ naturalWidth = size.width;
122
+ width = size.width;
123
+
124
+ set src(_value: string) {
125
+ this.onload?.();
126
+ }
127
+ },
128
+ );
129
+ vi.stubGlobal('URL', {
130
+ ...URL,
131
+ createObjectURL: vi.fn(() => 'blob:qr'),
132
+ revokeObjectURL: vi.fn(),
133
+ });
134
+ }
135
+
136
+ function stubCanvas() {
137
+ const canvasContext = {
138
+ drawImage: vi.fn(),
139
+ getImageData: vi.fn((_x: number, _y: number, width: number, height: number) => ({
140
+ data: new Uint8ClampedArray(width * height * 4),
141
+ height,
142
+ width,
143
+ })),
144
+ } as unknown as CanvasRenderingContext2D;
145
+
146
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext);
147
+ }
148
+
82
149
  describe('useCodeScanner', () => {
83
150
  beforeEach(() => {
84
151
  vi.clearAllMocks();
152
+ jsQrMocks.default.mockReturnValue(undefined);
153
+ });
154
+
155
+ afterEach(() => {
156
+ vi.unstubAllGlobals();
157
+ vi.restoreAllMocks();
85
158
  });
86
159
 
87
160
  it('starts scanning with QR code and barcode formats by default', async () => {
@@ -120,4 +193,74 @@ describe('useCodeScanner', () => {
120
193
 
121
194
  expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
122
195
  });
196
+
197
+ it('uses jsQR first for Safari uploaded QR images', async () => {
198
+ const handleScanSuccess = vi.fn();
199
+ jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
200
+ stubSafariBrowser();
201
+ stubImageElement();
202
+ stubCanvas();
203
+
204
+ render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
205
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
206
+
207
+ fireEvent.click(screen.getByText('scan file'));
208
+
209
+ await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('JSQR-CODE'));
210
+ expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 600, 400, {
211
+ inversionAttempts: 'attemptBoth',
212
+ });
213
+ expect(mocks.scanFileV2).not.toHaveBeenCalled();
214
+ });
215
+
216
+ it('falls back to html5-qrcode for Safari uploaded files when jsQR does not decode a QR code', async () => {
217
+ const handleScanSuccess = vi.fn();
218
+ mocks.scanFileV2.mockResolvedValueOnce({ decodedText: 'BARCODE-CODE' });
219
+ stubSafariBrowser();
220
+ stubImageElement();
221
+ stubCanvas();
222
+
223
+ render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
224
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
225
+
226
+ fireEvent.click(screen.getByText('scan file'));
227
+
228
+ await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('BARCODE-CODE'));
229
+ expect(jsQrMocks.default).toHaveBeenCalled();
230
+ expect(mocks.scanFileV2).toHaveBeenCalled();
231
+ });
232
+
233
+ it('tries multiple image scales before falling back from Safari jsQR scanning', async () => {
234
+ const handleScanSuccess = vi.fn();
235
+ Array.from({ length: 8 }).forEach((_, index) => {
236
+ jsQrMocks.default.mockReturnValueOnce(index === 7 ? { data: 'SCALED-CODE' } : undefined);
237
+ });
238
+ stubSafariBrowser();
239
+ stubImageElement({ width: 4000, height: 3000 });
240
+ stubCanvas();
241
+
242
+ render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
243
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
244
+
245
+ fireEvent.click(screen.getByText('scan file'));
246
+
247
+ await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('SCALED-CODE'));
248
+ expect(jsQrMocks.default).toHaveBeenCalledTimes(8);
249
+ });
250
+
251
+ it('tries enhanced grayscale QR images for blurry Safari uploads', async () => {
252
+ const handleScanSuccess = vi.fn();
253
+ jsQrMocks.default.mockReturnValueOnce(undefined).mockReturnValueOnce({ data: 'ENHANCED-CODE' });
254
+ stubSafariBrowser();
255
+ stubImageElement();
256
+ stubCanvas();
257
+
258
+ render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
259
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
260
+
261
+ fireEvent.click(screen.getByText('scan file'));
262
+
263
+ await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('ENHANCED-CODE'));
264
+ expect(jsQrMocks.default).toHaveBeenCalledTimes(2);
265
+ });
123
266
  });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
11
+ import jsQR from 'jsqr';
11
12
  import { useCallback, useEffect, useState } from 'react';
12
13
  import type { CodeFormatsToSupport } from './types';
13
14
 
@@ -26,6 +27,27 @@ type UseCodeScannerOptions = {
26
27
  onScanFailure?: () => void;
27
28
  };
28
29
 
30
+ type ImageDataVariant = {
31
+ imageData: ImageData;
32
+ maxSize: number;
33
+ };
34
+
35
+ type JsQRImageTransform = {
36
+ contrast?: number;
37
+ threshold?: number;
38
+ };
39
+
40
+ const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
41
+ const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
42
+ {},
43
+ { contrast: 3, threshold: 105 },
44
+ { contrast: 2, threshold: 105 },
45
+ { contrast: 3, threshold: 120 },
46
+ { contrast: 2, threshold: 120 },
47
+ { contrast: 3, threshold: 90 },
48
+ { contrast: 4, threshold: 105 },
49
+ ];
50
+
29
51
  export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
30
52
  Html5QrcodeSupportedFormats.QR_CODE,
31
53
  Html5QrcodeSupportedFormats.CODE_128,
@@ -62,6 +84,126 @@ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } =
62
84
  }
63
85
  }
64
86
 
87
+ function isSafariBrowser() {
88
+ const { userAgent, vendor } = navigator;
89
+ return /Apple/i.test(vendor) && /Safari/i.test(userAgent) && !/CriOS|FxiOS|EdgiOS|Chrome/i.test(userAgent);
90
+ }
91
+
92
+ function loadImage(file: File) {
93
+ return new Promise<HTMLImageElement>((resolve, reject) => {
94
+ const image = new Image();
95
+ const url = URL.createObjectURL(file);
96
+ const cleanup = () => URL.revokeObjectURL(url);
97
+
98
+ image.onload = () => {
99
+ cleanup();
100
+ resolve(image);
101
+ };
102
+ image.onerror = (event) => {
103
+ cleanup();
104
+ reject(event);
105
+ };
106
+ image.onabort = (event) => {
107
+ cleanup();
108
+ reject(event);
109
+ };
110
+ image.src = url;
111
+ });
112
+ }
113
+
114
+ function getScanImageSizes(naturalWidth: number, naturalHeight: number) {
115
+ const maxSize = Math.max(naturalWidth, naturalHeight);
116
+ const cappedMaxSize = Math.min(maxSize, QR_SCAN_IMAGE_SIZES[0]);
117
+ return Array.from(new Set([cappedMaxSize, ...QR_SCAN_IMAGE_SIZES.filter((size) => size < cappedMaxSize)])).sort(
118
+ (a, b) => b - a,
119
+ );
120
+ }
121
+
122
+ function getImageDataFromImage(image: HTMLImageElement, maxSize: number): ImageDataVariant | undefined {
123
+ const naturalWidth = image.naturalWidth || image.width;
124
+ const naturalHeight = image.naturalHeight || image.height;
125
+
126
+ const scale = Math.min(1, maxSize / Math.max(naturalWidth, naturalHeight));
127
+ const width = Math.max(1, Math.round(naturalWidth * scale));
128
+ const height = Math.max(1, Math.round(naturalHeight * scale));
129
+ const canvas = document.createElement('canvas');
130
+ canvas.width = width;
131
+ canvas.height = height;
132
+ const context = canvas.getContext('2d');
133
+ if (!context) {
134
+ return;
135
+ }
136
+
137
+ context.drawImage(image, 0, 0, width, height);
138
+ return {
139
+ imageData: context.getImageData(0, 0, width, height),
140
+ maxSize,
141
+ };
142
+ }
143
+
144
+ async function getImageDataVariants(file: File) {
145
+ const image = await loadImage(file);
146
+ const naturalWidth = image.naturalWidth || image.width;
147
+ const naturalHeight = image.naturalHeight || image.height;
148
+
149
+ if (!naturalWidth || !naturalHeight) {
150
+ return;
151
+ }
152
+
153
+ return getScanImageSizes(naturalWidth, naturalHeight)
154
+ .map((maxSize) => getImageDataFromImage(image, maxSize))
155
+ .filter((variant): variant is ImageDataVariant => !!variant);
156
+ }
157
+
158
+ function getTransformedImageData(imageData: ImageData, transform: JsQRImageTransform) {
159
+ const { contrast = 1, threshold } = transform;
160
+ if (contrast === 1 && threshold == null) {
161
+ return imageData.data;
162
+ }
163
+
164
+ const data = new Uint8ClampedArray(imageData.data);
165
+ for (let index = 0; index < data.length; index += 4) {
166
+ let luminance = data[index] * 0.299 + data[index + 1] * 0.587 + data[index + 2] * 0.114;
167
+ luminance = Math.max(0, Math.min(255, (luminance - 128) * contrast + 128));
168
+ if (threshold != null) {
169
+ luminance = luminance < threshold ? 0 : 255;
170
+ }
171
+ data[index] = luminance;
172
+ data[index + 1] = luminance;
173
+ data[index + 2] = luminance;
174
+ }
175
+ return data;
176
+ }
177
+
178
+ async function scanFileWithJsQR(file: File, formatsToSupport?: CodeFormatsToSupport) {
179
+ const formats = formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS;
180
+ if (!formats.includes(Html5QrcodeSupportedFormats.QR_CODE)) {
181
+ throw new Error('QR_CODE is not included in the requested formats');
182
+ }
183
+
184
+ const variants = await getImageDataVariants(file);
185
+ if (!variants?.length) {
186
+ throw new Error('Failed to prepare uploaded image for QR decoding');
187
+ }
188
+
189
+ for (const { imageData } of variants) {
190
+ for (const transform of QR_SCAN_IMAGE_TRANSFORMS) {
191
+ const data = getTransformedImageData(imageData, transform);
192
+ const qrCode = jsQR(data, imageData.width, imageData.height, { inversionAttempts: 'attemptBoth' });
193
+ if (qrCode?.data) {
194
+ return qrCode.data;
195
+ }
196
+ }
197
+ }
198
+
199
+ throw new Error('No QR code decoded by jsQR');
200
+ }
201
+
202
+ function shouldScanQrWithJsQR(formatsToSupport?: CodeFormatsToSupport) {
203
+ const formats = formatsToSupport?.length ? formatsToSupport : DEFAULT_CODE_FORMATS;
204
+ return formats.includes(Html5QrcodeSupportedFormats.QR_CODE);
205
+ }
206
+
65
207
  export function useCodeScanner({
66
208
  enabled,
67
209
  elementId,
@@ -95,6 +237,16 @@ export function useCodeScanner({
95
237
 
96
238
  const startScanFile = useCallback(
97
239
  async (file: File) => {
240
+ if (isSafariBrowser() && shouldScanQrWithJsQR(formatsToSupport)) {
241
+ try {
242
+ const decodedText = await scanFileWithJsQR(file, formatsToSupport);
243
+ onScanSuccess(decodedText);
244
+ return;
245
+ } catch {
246
+ // Fall through to html5-qrcode so barcode uploads still work in Safari.
247
+ }
248
+ }
249
+
98
250
  if (!scanner) {
99
251
  return;
100
252
  }
@@ -103,12 +255,12 @@ export function useCodeScanner({
103
255
  try {
104
256
  const result = await scanner.scanFileV2(file, false);
105
257
  onScanSuccess(result.decodedText);
106
- } catch (error) {
258
+ } catch {
107
259
  onScanFailure?.();
108
260
  await startScanCamera(scanner);
109
261
  }
110
262
  },
111
- [onScanFailure, onScanSuccess, scanner, startScanCamera],
263
+ [formatsToSupport, onScanFailure, onScanSuccess, scanner, startScanCamera],
112
264
  );
113
265
 
114
266
  useEffect(() => {
@@ -135,11 +135,15 @@ describe('linkageRulesRefresh action', () => {
135
135
  expect(handler).toHaveBeenCalledWith(ctx, { value: ['master-mounted'] });
136
136
  });
137
137
 
138
- it('runs linkage action on master model when forks exist in design mode', async () => {
138
+ it('skips master model when forks can handle flow in design mode', async () => {
139
139
  const handler = vi.fn(async () => {});
140
140
  const model: any = {
141
141
  isFork: false,
142
- forks: new Set([{}]),
142
+ forks: new Set([
143
+ {
144
+ getFlow: vi.fn(() => ({})),
145
+ },
146
+ ]),
143
147
  getFlow: vi.fn(() => ({})),
144
148
  getStepParams: vi.fn(() => ({ value: ['master'] })),
145
149
  context: {
@@ -157,7 +161,7 @@ describe('linkageRulesRefresh action', () => {
157
161
  flowKey: 'buttonSettings',
158
162
  });
159
163
 
160
- expect(handler).toHaveBeenCalledWith(ctx, { value: ['master'] });
164
+ expect(handler).not.toHaveBeenCalled();
161
165
  });
162
166
 
163
167
  it('runs linkage action on fork model and resolves params', async () => {
@@ -35,12 +35,8 @@ export const linkageRulesRefresh = defineAction({
35
35
  // Prefer running on the current model; fallback to blockModel when the current model doesn't own the flow.
36
36
  if (!hasFlow) return;
37
37
 
38
- // In runtime, only skip master when there are mounted forks that can handle the same flow.
38
+ // Skip master when unmounted forks can handle the same flow.
39
39
  // Otherwise master is likely the rendered model and still needs refresh.
40
- // In design mode, always refresh master so the currently edited model state stays in sync.
41
- const flowSettingsEnabled = Boolean(
42
- (ctx as any)?.flowSettingsEnabled || (model as any)?.context?.flowSettingsEnabled,
43
- );
44
40
  const hasForkWithFlow =
45
41
  !model?.isFork &&
46
42
  !!model?.forks?.size &&
@@ -49,7 +45,7 @@ export const linkageRulesRefresh = defineAction({
49
45
  return !!fork?.getFlow?.(flowKey);
50
46
  });
51
47
  const isMasterMounted = Boolean((model as any)?.context?.ref?.current);
52
- if (hasForkWithFlow && !isMasterMounted && !flowSettingsEnabled) {
48
+ if (hasForkWithFlow && !isMasterMounted) {
53
49
  return;
54
50
  }
55
51
 
@@ -121,10 +121,11 @@ const Columns = observer<any>(({ record, model, index }) => {
121
121
  fork.context.defineProperty('recordIndex', {
122
122
  get: () => index,
123
123
  });
124
+ const rendererKey = `${fork.uid}:${fork.forkId}`;
124
125
  const renderer = (
125
126
  <FlowModelRenderer
126
127
  showFlowSettings={{ showBorder: false, toolbarPosition: 'above' }}
127
- key={fork.uid}
128
+ key={rendererKey}
128
129
  model={fork}
129
130
  inputArgs={record}
130
131
  fallback={<Skeleton.Button size="small" />}