fix(variable): lazy loaded data should not be saved in the original c… (#5540)

* fix(variable): lazy loaded data should not be saved in the original context object

* fix: make unit test pass
This commit is contained in:
Zeke Zhang 2025-02-12 18:58:37 +08:00 committed by GitHub
parent 0cea7eb0a7
commit b680f3f9b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 78 additions and 30 deletions

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { raw, untracked } from '@formily/reactive'; import { untracked } from '@formily/reactive';
import { getValuesByPath } from '@nocobase/utils/client'; import { getValuesByPath } from '@nocobase/utils/client';
import _ from 'lodash'; import _ from 'lodash';
import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react'; import React, { createContext, useCallback, useEffect, useMemo, useRef } from 'react';
@ -18,6 +18,7 @@ import { getDataSourceHeaders } from '../data-source/utils';
import { useCompile } from '../schema-component'; import { useCompile } from '../schema-component';
import useBuiltInVariables from './hooks/useBuiltinVariables'; import useBuiltInVariables from './hooks/useBuiltinVariables';
import { VariableOption, VariablesContextType } from './types'; import { VariableOption, VariablesContextType } from './types';
import { cacheLazyLoadedValues, getCachedLazyLoadedValues } from './utils/cacheLazyLoadedValues';
import { filterEmptyValues } from './utils/filterEmptyValues'; import { filterEmptyValues } from './utils/filterEmptyValues';
import { getAction } from './utils/getAction'; import { getAction } from './utils/getAction';
import { getPath } from './utils/getPath'; import { getPath } from './utils/getPath';
@ -69,18 +70,18 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
}, []); }, []);
/** /**
* 1. `ctx` `path` * 1. Get value from `ctx` based on `path`
* 2. `key` `key` api `ctx` * 2. If a `key` does not exist and is an association field, fetch data from api and cache it in `ctx`
* 3. `key` `key` * 3. If a `key` does not exist and is not an association field, return the current value
*/ */
const getResult = useCallback( const getResult = useCallback(
async ( async (
variablePath: string, variablePath: string,
localVariables?: VariableOption[], localVariables?: VariableOption[],
options?: { options?: {
/** 第一次请求时,需要包含的关系字段 */ /** Related fields that need to be included in the first request */
appends?: string[]; appends?: string[];
/** do not request when the association field is empty */ /** Do not request when the association field is empty */
doNotRequest?: boolean; doNotRequest?: boolean;
/** /**
* The operator related to the current field, provided when parsing the default value of the field * The operator related to the current field, provided when parsing the default value of the field
@ -115,12 +116,17 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
} }
const key = list[index]; const key = list[index];
const { fieldPath } = getFieldPath(list.slice(0, index + 1).join('.'), _variableToCollectionName); const currentVariablePath = list.slice(0, index + 1).join('.');
const { fieldPath } = getFieldPath(currentVariablePath, _variableToCollectionName);
const associationField: CollectionFieldOptions_deprecated = getCollectionJoinField(fieldPath, dataSource); const associationField: CollectionFieldOptions_deprecated = getCollectionJoinField(fieldPath, dataSource);
const collectionPrimaryKey = getCollection(collectionName, dataSource)?.getPrimaryKey(); const collectionPrimaryKey = getCollection(collectionName, dataSource)?.getPrimaryKey();
if (Array.isArray(current)) { if (Array.isArray(current)) {
const result = current.map((item) => { const result = current.map((item) => {
if (!options?.doNotRequest && shouldToRequest(item?.[key]) && item?.[collectionPrimaryKey] != null) { if (
!options?.doNotRequest &&
shouldToRequest(item?.[key], item, currentVariablePath) &&
item?.[collectionPrimaryKey] != null
) {
if (associationField?.target) { if (associationField?.target) {
const url = `/${collectionName}/${ const url = `/${collectionName}/${
item[associationField.sourceKey || collectionPrimaryKey] item[associationField.sourceKey || collectionPrimaryKey]
@ -138,19 +144,20 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
}) })
.then((data) => { .then((data) => {
clearRequested(url); clearRequested(url);
item[key] = data.data.data; const value = data.data.data;
return item[key]; cacheLazyLoadedValues(item, currentVariablePath, value);
return value;
}); });
stashRequested(url, result); stashRequested(url, result);
return result; return result;
} }
} }
return item?.[key]; return getCachedLazyLoadedValues(item, currentVariablePath) || item?.[key];
}); });
current = removeThroughCollectionFields(_.flatten(await Promise.all(result)), associationField); current = removeThroughCollectionFields(_.flatten(await Promise.all(result)), associationField);
} else if ( } else if (
!options?.doNotRequest && !options?.doNotRequest &&
shouldToRequest(current[key]) && shouldToRequest(current[key], current, currentVariablePath) &&
current[collectionPrimaryKey] != null && current[collectionPrimaryKey] != null &&
associationField?.target associationField?.target
) { ) {
@ -173,15 +180,18 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
clearRequested(url); clearRequested(url);
} }
// fix https://nocobase.height.app/T-3144使用 `raw` 方法是为了避免触发 autorun以修复 T-3144 的错误 const value = data.data.data;
if (!raw(current)[key]) { if (!getCachedLazyLoadedValues(current, currentVariablePath)) {
// 把接口返回的数据保存起来,避免重复请求 // Cache the API response data to avoid repeated requests
raw(current)[key] = data.data.data; cacheLazyLoadedValues(current, currentVariablePath, value);
} }
current = removeThroughCollectionFields(getValuesByPath(current, key), associationField); current = removeThroughCollectionFields(value, associationField);
} else { } else {
current = removeThroughCollectionFields(getValuesByPath(current, key), associationField); current = removeThroughCollectionFields(
getCachedLazyLoadedValues(current, currentVariablePath) || getValuesByPath(current, key),
associationField,
);
} }
if (associationField?.target) { if (associationField?.target) {
@ -202,7 +212,7 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
); );
/** /**
* * Register a global variable
*/ */
const registerVariable = useCallback( const registerVariable = useCallback(
(variableOption: VariableOption) => { (variableOption: VariableOption) => {
@ -248,18 +258,18 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
const parseVariable = useCallback( const parseVariable = useCallback(
/** /**
* * Parse the variable string to the actual value
* @param str * @param str Variable string
* @param localVariables * @param localVariables Local variables, will be cleared after parsing
* @returns * @returns
*/ */
async ( async (
str: string, str: string,
localVariables?: VariableOption | VariableOption[], localVariables?: VariableOption | VariableOption[],
options?: { options?: {
/** 第一次请求时,需要包含的关系字段 */ /** Related fields that need to be included in the first request */
appends?: string[]; appends?: string[];
/** do not request when the association field is empty */ /** Do not request when the association field is empty */
doNotRequest?: boolean; doNotRequest?: boolean;
/** /**
* The operator related to the current field, provided when parsing the default value of the field * The operator related to the current field, provided when parsing the default value of the field
@ -304,7 +314,7 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
const { fieldPath, dataSource } = getFieldPath(path, _variableToCollectionName); const { fieldPath, dataSource } = getFieldPath(path, _variableToCollectionName);
let result = getCollectionJoinField(fieldPath, dataSource); let result = getCollectionJoinField(fieldPath, dataSource);
// 当仅有一个例如 `$user` 这样的字符串时,需要拼一个假的 `collectionField` 返回 // When there is only a string like `$user`, a fake `collectionField` needs to be returned
if (!result && !path.includes('.')) { if (!result && !path.includes('.')) {
result = { result = {
target: _variableToCollectionName[path]?.collectionName, target: _variableToCollectionName[path]?.collectionName,
@ -347,13 +357,17 @@ VariablesProvider.displayName = 'VariablesProvider';
export default VariablesProvider; export default VariablesProvider;
function shouldToRequest(value) { function shouldToRequest(value, variableCtx: Record<string, any>, variablePath: string) {
let result = false; let result = false;
// value 有可能是一个响应式对象,使用 untracked 可以避免意外触发 autorun if (getCachedLazyLoadedValues(variableCtx, variablePath)) {
return false;
}
// value may be a reactive object, using untracked to avoid unexpected autorun
untracked(() => { untracked(() => {
// fix https://nocobase.height.app/T-2502 // fix https://nocobase.height.app/T-2502
// 兼容 `对多` 和 `对一` 子表单子表格字段的情况 // Compatible with `xxx to many` and `xxx to one` subform fields and subtable fields
if (JSON.stringify(value) === '[{}]' || JSON.stringify(value) === '{}') { if (JSON.stringify(value) === '[{}]' || JSON.stringify(value) === '{}') {
result = true; result = true;
return; return;
@ -392,8 +406,9 @@ function mergeVariableToCollectionNameWithLocalVariables(
} }
/** /**
* * Remove `through collection fields` from association fields.
* * If `through collection fields` exist in association fields when creating new records,
* it will cause errors during submission, so they need to be removed.
* @param value * @param value
* @param associationField * @param associationField
* @returns * @returns

View File

@ -332,6 +332,14 @@ describe('useVariables', () => {
name: '$user.belongsToField', name: '$user.belongsToField',
}); });
}); });
await waitFor(async () => {
// After lazy loading the association field value, the original $user variable value should not contain the association field value
expect(await result.current.parseVariable('{{ $user }}').then(({ value }) => value)).toEqual({
id: 0,
nickname: 'from request',
});
});
}); });
it('set doNotRequest to true to ensure the result is empty', async () => { it('set doNotRequest to true to ensure the result is empty', async () => {

View File

@ -0,0 +1,25 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
const cache = new Map<Record<string, any>, any>();
export const cacheLazyLoadedValues = (variableCtx: Record<string, any>, variablePath: string, value: any) => {
const cachedValue = cache.get(variableCtx);
if (cachedValue) {
cachedValue[variablePath] = value;
} else {
cache.set(variableCtx, { [variablePath]: value });
}
};
export const getCachedLazyLoadedValues = (variableCtx: Record<string, any>, variablePath: string) => {
const cachedValue = cache.get(variableCtx);
return cachedValue?.[variablePath];
};