diff --git a/app/client/src/ce/actions/evaluationActionsList.ts b/app/client/src/ce/actions/evaluationActionsList.ts index 34a0388110cf..1e248a9f7248 100644 --- a/app/client/src/ce/actions/evaluationActionsList.ts +++ b/app/client/src/ce/actions/evaluationActionsList.ts @@ -47,6 +47,18 @@ export const LOG_REDUX_ACTIONS = { [ReduxActionTypes.UPDATE_ACTION_PROPERTY]: true, }; +export const JS_ACTIONS = [ + ReduxActionTypes.CREATE_JS_ACTION_SUCCESS, + ReduxActionTypes.DELETE_JS_ACTION_SUCCESS, + ReduxActionTypes.COPY_JS_ACTION_SUCCESS, + ReduxActionTypes.MOVE_JS_ACTION_SUCCESS, + ReduxActionErrorTypes.FETCH_JS_ACTIONS_ERROR, + ReduxActionTypes.FETCH_JS_ACTIONS_FOR_PAGE_SUCCESS, + ReduxActionTypes.FETCH_JS_ACTIONS_VIEW_MODE_SUCCESS, + ReduxActionErrorTypes.FETCH_JS_ACTIONS_VIEW_MODE_ERROR, + ReduxActionTypes.UPDATE_JS_ACTION_BODY_SUCCESS, +]; + export const EVALUATE_REDUX_ACTIONS = [ ...FIRST_EVAL_REDUX_ACTIONS, // Actions @@ -65,15 +77,7 @@ export const EVALUATE_REDUX_ACTIONS = [ ReduxActionErrorTypes.RUN_ACTION_ERROR, ReduxActionTypes.CLEAR_ACTION_RESPONSE, // JS Actions - ReduxActionTypes.CREATE_JS_ACTION_SUCCESS, - ReduxActionTypes.DELETE_JS_ACTION_SUCCESS, - ReduxActionTypes.COPY_JS_ACTION_SUCCESS, - ReduxActionTypes.MOVE_JS_ACTION_SUCCESS, - ReduxActionErrorTypes.FETCH_JS_ACTIONS_ERROR, - ReduxActionTypes.FETCH_JS_ACTIONS_FOR_PAGE_SUCCESS, - ReduxActionTypes.FETCH_JS_ACTIONS_VIEW_MODE_SUCCESS, - ReduxActionErrorTypes.FETCH_JS_ACTIONS_VIEW_MODE_ERROR, - ReduxActionTypes.UPDATE_JS_ACTION_BODY_SUCCESS, + ...JS_ACTIONS, // App Data ReduxActionTypes.SET_APP_MODE, ReduxActionTypes.FETCH_USER_DETAILS_SUCCESS, diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 8b9b26f49580..930b64258c60 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -16,6 +16,7 @@ import type { import type { AppLayoutConfig } from "reducers/entityReducers/pageListReducer"; import type { DSLWidget } from "WidgetProvider/constants"; import type { LayoutSystemTypeConfig } from "layoutSystems/types"; +import type { AffectedJSObjects } from "sagas/EvaluationsSagaUtils"; export const ReduxSagaChannels = { WEBSOCKET_APP_LEVEL_WRITE_CHANNEL: "WEBSOCKET_APP_LEVEL_WRITE_CHANNEL", @@ -1168,7 +1169,9 @@ export interface ReduxAction { type: ReduxActionType | ReduxActionErrorType; payload: T; } - +export interface BufferedReduxAction extends ReduxAction { + affectedJSObjects: AffectedJSObjects; +} export type ReduxActionWithoutPayload = Pick, "type">; export interface ReduxActionWithMeta extends ReduxAction { @@ -1186,6 +1189,7 @@ export type AnyReduxAction = ReduxAction | ReduxActionWithoutPayload; export interface EvaluationReduxAction extends ReduxAction { postEvalActions?: Array; + affectedJSObjects?: AffectedJSObjects; } export interface PromisePayload { diff --git a/app/client/src/ce/sagas/InferAffectedJSObjects.ts b/app/client/src/ce/sagas/InferAffectedJSObjects.ts new file mode 100644 index 000000000000..a2cd3e5d347a --- /dev/null +++ b/app/client/src/ce/sagas/InferAffectedJSObjects.ts @@ -0,0 +1,71 @@ +import type { + BufferedReduxAction, + ReduxAction, +} from "@appsmith/constants/ReduxActionConstants"; +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} from "@appsmith/constants/ReduxActionConstants"; +import { JS_ACTIONS } from "@appsmith/actions/evaluationActionsList"; +import type { AffectedJSObjects } from "sagas/EvaluationsSagaUtils"; +import type { JSCollection } from "entities/JSCollection"; + +export function getAffectedJSObjectIdsFromJSAction( + action: ReduxAction | BufferedReduxAction, +): AffectedJSObjects { + if (!JS_ACTIONS.includes(action.type)) { + return { + ids: [], + isAllAffected: false, + }; + } + // only JS actions here + action as ReduxAction; + // When fetching JSActions fails, we need to diff all JSObjects because the reducer updates it + // to empty collection + if ( + action.type === ReduxActionErrorTypes.FETCH_JS_ACTIONS_ERROR || + action.type === ReduxActionErrorTypes.FETCH_JS_ACTIONS_VIEW_MODE_ERROR + ) { + return { + isAllAffected: true, + ids: [], + }; + } + + const { payload } = action as ReduxAction<{ + data: JSCollection; + }> & + ReduxAction; + // some actions have within data property of the action payload, we need to extract it from there + const innerData = payload?.data || payload; + + const ids = Array.isArray(innerData) + ? innerData.map(({ id }) => id) + : [innerData.id]; + + return { ids, isAllAffected: false }; +} + +function getAffectedJSObjectIdsFromBufferedAction( + action: ReduxAction | BufferedReduxAction, +): AffectedJSObjects { + if (action.type !== ReduxActionTypes.BUFFERED_ACTION) { + return { + ids: [], + isAllAffected: false, + }; + } + // only Buffered actions here + return ( + (action as BufferedReduxAction).affectedJSObjects || { + ids: [], + isAllAffected: false, + } + ); +} + +export const AFFECTED_JS_OBJECTS_FNS = [ + getAffectedJSObjectIdsFromJSAction, + getAffectedJSObjectIdsFromBufferedAction, +]; diff --git a/app/client/src/ee/sagas/InferAffectedJSObjects.ts b/app/client/src/ee/sagas/InferAffectedJSObjects.ts new file mode 100644 index 000000000000..adca6fca31f0 --- /dev/null +++ b/app/client/src/ee/sagas/InferAffectedJSObjects.ts @@ -0,0 +1 @@ +export * from "ce/sagas/InferAffectedJSObjects"; diff --git a/app/client/src/sagas/EvaluationSaga.utils.ts b/app/client/src/sagas/EvaluationSaga.utils.ts deleted file mode 100644 index b1aea041ea16..000000000000 --- a/app/client/src/sagas/EvaluationSaga.utils.ts +++ /dev/null @@ -1,41 +0,0 @@ -import log from "loglevel"; -import type { DiffWithNewTreeState } from "workers/Evaluation/helpers"; - -export const parseUpdatesAndDeleteUndefinedUpdates = ( - updates: string, -): DiffWithNewTreeState[] => { - let parsedUpdates = []; - try { - //Parse updates from a string - parsedUpdates = JSON.parse(updates); - } catch (e) { - log.error("Failed to parse updates", e, updates); - return []; - } - - //delete all undefined properties from the state - const { deleteUpdates, regularUpdates } = parsedUpdates.reduce( - (acc: any, curr: any) => { - const { kind, path, rhs } = curr; - - if (rhs === undefined) { - //ignore any new undefined updates to the state if the value is undefined - if (kind === "N") { - return acc; - } - //convert undefined updates to delete updates - if (kind === "E") { - acc.deleteUpdates.push({ kind: "D", path }); - return acc; - } - } - - acc.regularUpdates.push(curr); - return acc; - }, - { regularUpdates: [], deleteUpdates: [] }, - ); - - const consolidatedUpdates = [...regularUpdates, ...deleteUpdates]; - return consolidatedUpdates; -}; diff --git a/app/client/src/sagas/EvaluationsSaga.test.ts b/app/client/src/sagas/EvaluationsSaga.test.ts index cbae471f98ba..ec9a0f8e5b52 100644 --- a/app/client/src/sagas/EvaluationsSaga.test.ts +++ b/app/client/src/sagas/EvaluationsSaga.test.ts @@ -1,4 +1,9 @@ -import { evaluateTreeSaga, evalWorker } from "./EvaluationsSaga"; +import { + defaultAffectedJSObjects, + evalQueueBuffer, + evaluateTreeSaga, + evalWorker, +} from "./EvaluationsSaga"; import { expectSaga } from "redux-saga-test-plan"; import { EVAL_WORKER_ACTIONS } from "@appsmith/workers/Evaluation/evalWorkerActions"; import { select } from "redux-saga/effects"; @@ -7,6 +12,14 @@ import { getAllActionValidationConfig } from "@appsmith//selectors/entitiesSelec import { getSelectedAppTheme } from "selectors/appThemingSelectors"; import { getAppMode } from "@appsmith/selectors/applicationSelectors"; import * as log from "loglevel"; + +import type { ReduxAction } from "@appsmith/constants/ReduxActionConstants"; +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} from "@appsmith/constants/ReduxActionConstants"; +import { fetchPluginFormConfigsSuccess } from "actions/pluginActions"; +import { createJSCollectionSuccess } from "actions/jsActionActions"; jest.mock("loglevel"); describe("evaluateTreeSaga", () => { @@ -37,6 +50,7 @@ describe("evaluateTreeSaga", () => { appMode: false, widgetsMeta: {}, shouldRespondWithLogs: true, + affectedJSObjects: { ids: [], isAllAffected: false }, }) .run(); }); @@ -64,7 +78,110 @@ describe("evaluateTreeSaga", () => { appMode: false, widgetsMeta: {}, shouldRespondWithLogs: false, + affectedJSObjects: { ids: [], isAllAffected: false }, }) .run(); }); + test("should propagate affectedJSObjects property to evaluation action", async () => { + const unEvalAndConfigTree = { unEvalTree: {}, configTree: {} }; + const affectedJSObjects = { + isAllAffected: false, + ids: ["1", "2"], + }; + + return expectSaga( + evaluateTreeSaga, + unEvalAndConfigTree, + [], + undefined, + undefined, + undefined, + affectedJSObjects, + ) + .provide([ + [select(getAllActionValidationConfig), {}], + [select(getWidgets), {}], + [select(getMetaWidgets), {}], + [select(getSelectedAppTheme), {}], + [select(getAppMode), false], + [select(getWidgetsMeta), {}], + ]) + .call(evalWorker.request, EVAL_WORKER_ACTIONS.EVAL_TREE, { + unevalTree: unEvalAndConfigTree, + widgetTypeConfigMap: undefined, + widgets: {}, + theme: {}, + shouldReplay: true, + allActionValidationConfig: {}, + forceEvaluation: false, + metaWidgets: {}, + appMode: false, + widgetsMeta: {}, + shouldRespondWithLogs: false, + affectedJSObjects, + }) + .run(); + }); +}); + +describe("evalQueueBuffer", () => { + test("should return a buffered action with the default affectedJSObjects state for an action which does not have affectedJSObjects associated to it", () => { + const buffer = evalQueueBuffer(); + // this action does not generate an affectedJSObject + buffer.put(fetchPluginFormConfigsSuccess({} as any)); + const bufferedAction = buffer.take(); + expect(bufferedAction).toEqual({ + type: ReduxActionTypes.BUFFERED_ACTION, + affectedJSObjects: defaultAffectedJSObjects, + postEvalActions: [], + }); + }); + test("should club all JS actions affectedJSObjects's ids", () => { + const buffer = evalQueueBuffer(); + buffer.put(createJSCollectionSuccess({ id: "1" } as any)); + buffer.put(createJSCollectionSuccess({ id: "2" } as any)); + const bufferedAction = buffer.take(); + expect(bufferedAction).toEqual({ + type: ReduxActionTypes.BUFFERED_ACTION, + affectedJSObjects: { ids: ["1", "2"], isAllAffected: false }, + postEvalActions: [], + }); + }); + test("should return all JS actions that have changed when there is a pending action which affects all JS actions ", () => { + const buffer = evalQueueBuffer(); + buffer.put(createJSCollectionSuccess({ id: "1" } as any)); + // this action triggers an isAllAffected flag + buffer.put({ + type: ReduxActionErrorTypes.FETCH_JS_ACTIONS_ERROR, + } as ReduxAction); + // queue is not empty + expect(buffer.isEmpty()).not.toBeTruthy(); + + const bufferedAction = buffer.take(); + expect(bufferedAction).toEqual({ + type: ReduxActionTypes.BUFFERED_ACTION, + affectedJSObjects: { ids: [], isAllAffected: true }, + postEvalActions: [], + }); + expect(buffer.isEmpty()).toBeTruthy(); + }); + test("should reset the collectedAffectedJSObjects after the buffered action has been dequeued and the subsequent actions should have the defaultAffectedJSObjects", () => { + const buffer = evalQueueBuffer(); + buffer.put(createJSCollectionSuccess({ id: "1" } as any)); + const bufferedAction = buffer.take(); + expect(bufferedAction).toEqual({ + type: ReduxActionTypes.BUFFERED_ACTION, + affectedJSObjects: { ids: ["1"], isAllAffected: false }, + postEvalActions: [], + }); + expect(buffer.isEmpty()).toBeTruthy(); + // this action does not generate an affectedJSObject, So the subsequent buffered action should have default affectedJSObjects + buffer.put(fetchPluginFormConfigsSuccess({ id: "1" } as any)); + const bufferedActionsWithDefaultAffectedJSObjects = buffer.take(); + expect(bufferedActionsWithDefaultAffectedJSObjects).toEqual({ + type: ReduxActionTypes.BUFFERED_ACTION, + affectedJSObjects: defaultAffectedJSObjects, + postEvalActions: [], + }); + }); }); diff --git a/app/client/src/sagas/EvaluationsSaga.ts b/app/client/src/sagas/EvaluationsSaga.ts index 80237723d244..6e85d1145662 100644 --- a/app/client/src/sagas/EvaluationsSaga.ts +++ b/app/client/src/sagas/EvaluationsSaga.ts @@ -103,7 +103,11 @@ import { waitForWidgetConfigBuild } from "./InitSagas"; import { logDynamicTriggerExecution } from "@appsmith/sagas/analyticsSaga"; import { selectFeatureFlags } from "@appsmith/selectors/featureFlagsSelectors"; import { fetchFeatureFlagsInit } from "actions/userActions"; -import { parseUpdatesAndDeleteUndefinedUpdates } from "./EvaluationSaga.utils"; +import type { AffectedJSObjects } from "./EvaluationsSagaUtils"; +import { + getAffectedJSObjectIdsFromAction, + parseUpdatesAndDeleteUndefinedUpdates, +} from "./EvaluationsSagaUtils"; import { getFeatureFlagsFetched } from "selectors/usersSelectors"; import { getIsCurrentEditorWorkflowType } from "@appsmith/selectors/workflowSelectors"; import { evalErrorHandler } from "./EvalErrorHandler"; @@ -248,6 +252,7 @@ export function* evaluateTreeSaga( shouldReplay = true, forceEvaluation = false, requiresLogging = false, + affectedJSObjects: AffectedJSObjects = defaultAffectedJSObjects, ) { const allActionValidationConfig: ReturnType< typeof getAllActionValidationConfig @@ -280,6 +285,7 @@ export function* evaluateTreeSaga( appMode, widgetsMeta, shouldRespondWithLogs, + affectedJSObjects, }; const workerResponse: EvalTreeResponseData = yield call( @@ -479,15 +485,44 @@ export function* validateProperty( return response; } -function evalQueueBuffer() { +// We are clubbing all pending action's affected JS objects into the buffered action +// So that during that evaluation cycle all affected JS objects are correctly diffed +function mergeJSBufferedActions( + prevAffectedJSAction: AffectedJSObjects, + newAffectedJSAction: AffectedJSObjects, +) { + if (prevAffectedJSAction.isAllAffected || newAffectedJSAction.isAllAffected) { + return { + isAllAffected: true, + ids: [], + }; + } + return { + isAllAffected: false, + ids: [...prevAffectedJSAction.ids, ...newAffectedJSAction.ids], + }; +} +export const defaultAffectedJSObjects: AffectedJSObjects = { + isAllAffected: false, + ids: [], +}; +export function evalQueueBuffer() { let canTake = false; let collectedPostEvalActions: any = []; + let collectedAffectedJSObjects: AffectedJSObjects = defaultAffectedJSObjects; + const take = () => { if (canTake) { const resp = collectedPostEvalActions; collectedPostEvalActions = []; + const affectedJSObjects = collectedAffectedJSObjects; + collectedAffectedJSObjects = defaultAffectedJSObjects; canTake = false; - return { postEvalActions: resp, type: ReduxActionTypes.BUFFERED_ACTION }; + return { + postEvalActions: resp, + affectedJSObjects, + type: ReduxActionTypes.BUFFERED_ACTION, + }; } }; const flush = () => { @@ -503,6 +538,13 @@ function evalQueueBuffer() { return; } canTake = true; + // extract the affected JS action ids from the action and pass them + // as a part of the buffered action + const affectedJSObjects = getAffectedJSObjectIdsFromAction(action); + collectedAffectedJSObjects = mergeJSBufferedActions( + collectedAffectedJSObjects, + affectedJSObjects, + ); const postEvalActions = getPostEvalActions(action); collectedPostEvalActions.push(...postEvalActions); @@ -552,10 +594,12 @@ function* evalAndLintingHandler( shouldReplay: boolean; forceEvaluation: boolean; requiresLogging: boolean; + affectedJSObjects: AffectedJSObjects; }>, ) { const span = startRootSpan("evalAndLintingHandler"); - const { forceEvaluation, requiresLogging, shouldReplay } = options; + const { affectedJSObjects, forceEvaluation, requiresLogging, shouldReplay } = + options; const requiresLinting = getRequiresLinting(action); @@ -589,6 +633,7 @@ function* evalAndLintingHandler( shouldReplay, forceEvaluation, requiresLogging, + affectedJSObjects, ), ); } @@ -640,19 +685,30 @@ function* evaluationChangeListenerSaga(): any { yield fork(evalAndLintingHandler, false, initAction, { shouldReplay: false, forceEvaluation: false, + // during startup all JS objects are affected + affectedJSObjects: { + ids: [], + isAllAffected: true, + }, }); const evtActionChannel: ActionPattern> = yield actionChannel( EVAL_AND_LINT_REDUX_ACTIONS, evalQueueBuffer(), ); + while (true) { const action: EvaluationReduxAction = yield take(evtActionChannel); + // We are dequing actions from the buffer and inferring the JS actions affected by each + // action. Through this we know ahead the nodes we need to specifically diff, thereby improving performance. + const affectedJSObjects = getAffectedJSObjectIdsFromAction(action); + yield call(evalAndLintingHandler, true, action, { shouldReplay: get(action, "payload.shouldReplay"), forceEvaluation: shouldForceEval(action), requiresLogging: shouldLog(action), + affectedJSObjects, }); } } diff --git a/app/client/src/sagas/EvaluationsSagaUtils.test.ts b/app/client/src/sagas/EvaluationsSagaUtils.test.ts new file mode 100644 index 000000000000..51f22497ed17 --- /dev/null +++ b/app/client/src/sagas/EvaluationsSagaUtils.test.ts @@ -0,0 +1,96 @@ +import { getAffectedJSObjectIdsFromAction } from "./EvaluationsSagaUtils"; +import { + copyJSCollectionSuccess, + createJSCollectionSuccess, + deleteJSCollectionSuccess, + fetchJSCollectionsForPageSuccess, + moveJSCollectionSuccess, +} from "actions/jsActionActions"; +import { updateJSCollectionBodySuccess } from "actions/jsPaneActions"; +import type { JSCollection } from "entities/JSCollection"; +import type { + BufferedReduxAction, + ReduxAction, +} from "@appsmith/constants/ReduxActionConstants"; +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} from "@appsmith/constants/ReduxActionConstants"; + +describe("getAffectedJSObjectIdsFromAction", () => { + const jsObject1 = { id: "1234" } as JSCollection; + const jsObject2 = { id: "5678" } as JSCollection; + const jsCollection: JSCollection[] = [jsObject1, jsObject2]; + + test("should return a default response for an empty action ", () => { + const result = getAffectedJSObjectIdsFromAction( + null as unknown as ReduxAction, + ); + expect(result).toEqual({ ids: [], isAllAffected: false }); + }); + test("should return a default response for a non JS action and non Buffered redux action ", () => { + const action: ReduxAction = { + type: ReduxActionTypes.FETCH_PLUGIN_FORM_CONFIGS_SUCCESS, + payload: {}, + }; + const result = getAffectedJSObjectIdsFromAction(action); + expect(result).toEqual({ ids: [], isAllAffected: false }); + }); + + describe("infer correct affected action ids for a Buffered Redux Action", () => { + test("should return an empty ids when there are no affectedJSObjects in the Buffered action", () => { + const action: ReduxAction = { + type: ReduxActionTypes.BUFFERED_ACTION, + payload: {}, + }; + const result = getAffectedJSObjectIdsFromAction(action); + expect(result).toEqual({ ids: [], isAllAffected: false }); + }); + test("should return the buffered action's ids ", () => { + const action: BufferedReduxAction = { + type: ReduxActionTypes.BUFFERED_ACTION, + affectedJSObjects: { ids: ["1234", "5678"], isAllAffected: false }, + payload: {}, + }; + const result = getAffectedJSObjectIdsFromAction(action); + expect(result).toEqual({ ids: ["1234", "5678"], isAllAffected: false }); + }); + test("should return the buffered action's isAllAffected property", () => { + const action: BufferedReduxAction = { + type: ReduxActionTypes.BUFFERED_ACTION, + affectedJSObjects: { ids: [], isAllAffected: true }, + payload: {}, + }; + const result = getAffectedJSObjectIdsFromAction(action); + expect(result).toEqual({ isAllAffected: true, ids: [] }); + }); + }); + + test.each([ + [createJSCollectionSuccess, jsObject1, ["1234"]], + [deleteJSCollectionSuccess, jsObject1, ["1234"]], + [copyJSCollectionSuccess, jsObject1, ["1234"]], + [moveJSCollectionSuccess, jsObject1, ["1234"]], + [updateJSCollectionBodySuccess, { data: jsObject1 }, ["1234"]], + [fetchJSCollectionsForPageSuccess, jsCollection, ["1234", "5678"]], + ])( + "should return the correct affected JSObject ids for action %p with input %p and expected to be %p", + (action, input, expected) => { + const result = getAffectedJSObjectIdsFromAction( + action(input as JSCollection & JSCollection[] & { data: JSCollection }), + ); + expect(result).toEqual({ ids: expected, isAllAffected: false }); + }, + ); + test("should return isAllAffected to be true when there are JS errors", () => { + [ + ReduxActionErrorTypes.FETCH_JS_ACTIONS_ERROR, + ReduxActionErrorTypes.FETCH_JS_ACTIONS_VIEW_MODE_ERROR, + ].forEach((actionType) => { + const result = getAffectedJSObjectIdsFromAction({ + type: actionType, + } as ReduxAction); + expect(result).toEqual({ isAllAffected: true, ids: [] }); + }); + }); +}); diff --git a/app/client/src/sagas/EvaluationsSagaUtils.ts b/app/client/src/sagas/EvaluationsSagaUtils.ts new file mode 100644 index 000000000000..e249fbea4de5 --- /dev/null +++ b/app/client/src/sagas/EvaluationsSagaUtils.ts @@ -0,0 +1,85 @@ +import type { + BufferedReduxAction, + ReduxAction, +} from "@appsmith/constants/ReduxActionConstants"; +import { AFFECTED_JS_OBJECTS_FNS } from "@appsmith/sagas/InferAffectedJSObjects"; +import log from "loglevel"; +import type { DiffWithNewTreeState } from "workers/Evaluation/helpers"; + +export const parseUpdatesAndDeleteUndefinedUpdates = ( + updates: string, +): DiffWithNewTreeState[] => { + let parsedUpdates = []; + try { + //Parse updates from a string + parsedUpdates = JSON.parse(updates); + } catch (e) { + log.error("Failed to parse updates", e, updates); + return []; + } + + //delete all undefined properties from the state + const { deleteUpdates, regularUpdates } = parsedUpdates.reduce( + (acc: any, curr: any) => { + const { kind, path, rhs } = curr; + + if (rhs === undefined) { + //ignore any new undefined updates to the state if the value is undefined + if (kind === "N") { + return acc; + } + //convert undefined updates to delete updates + if (kind === "E") { + acc.deleteUpdates.push({ kind: "D", path }); + return acc; + } + } + + acc.regularUpdates.push(curr); + return acc; + }, + { regularUpdates: [], deleteUpdates: [] }, + ); + + const consolidatedUpdates = [...regularUpdates, ...deleteUpdates]; + return consolidatedUpdates; +}; + +export interface AffectedJSObjects { + ids: string[]; + isAllAffected: boolean; +} + +const mergeAffectedJSObjects = ( + action: ReduxAction | BufferedReduxAction, +) => { + return AFFECTED_JS_OBJECTS_FNS.reduce( + (acc, affectedJSObjectsFn) => { + // when either of the action isAllJSObjectsAffected return true. + // In this case perform diff on all js objects + if (acc.isAllAffected) { + return acc; + } + acc = { + isAllAffected: + acc.isAllAffected || affectedJSObjectsFn(action).isAllAffected, + ids: [...acc.ids, ...affectedJSObjectsFn(action).ids], + }; + + return acc; + }, + { ids: [], isAllAffected: false } as AffectedJSObjects, + ); +}; +// Infer from an action the JSObjects that are affected by a Redux action. +export function getAffectedJSObjectIdsFromAction( + action: ReduxAction | BufferedReduxAction, +): AffectedJSObjects { + if (!action) + return { + ids: [], + isAllAffected: false, + }; + + return mergeAffectedJSObjects(action); +} diff --git a/app/client/src/workers/Evaluation/__tests__/generateOpimisedUpdates.test.ts b/app/client/src/workers/Evaluation/__tests__/generateOpimisedUpdates.test.ts index ce83096d6ac9..f508047af676 100644 --- a/app/client/src/workers/Evaluation/__tests__/generateOpimisedUpdates.test.ts +++ b/app/client/src/workers/Evaluation/__tests__/generateOpimisedUpdates.test.ts @@ -4,7 +4,7 @@ import produce from "immer"; import { klona } from "klona/full"; import { range } from "lodash"; import moment from "moment"; -import { parseUpdatesAndDeleteUndefinedUpdates } from "sagas/EvaluationSaga.utils"; +import { parseUpdatesAndDeleteUndefinedUpdates } from "sagas/EvaluationsSagaUtils"; import type { DataTreeEvaluationProps, EvaluationError, diff --git a/app/client/src/workers/Evaluation/handlers/evalTree.ts b/app/client/src/workers/Evaluation/handlers/evalTree.ts index 3070d0f49c24..46160713e9d6 100644 --- a/app/client/src/workers/Evaluation/handlers/evalTree.ts +++ b/app/client/src/workers/Evaluation/handlers/evalTree.ts @@ -63,6 +63,7 @@ export function evalTree(request: EvalWorkerSyncRequest) { let isNewWidgetAdded = false; const { + affectedJSObjects, allActionValidationConfig, appMode, forceEvaluation, @@ -182,6 +183,7 @@ export function evalTree(request: EvalWorkerSyncRequest) { unevalTree, configTree, webworkerTelemetry, + affectedJSObjects, ), ); diff --git a/app/client/src/workers/Evaluation/handlers/evalTrigger.ts b/app/client/src/workers/Evaluation/handlers/evalTrigger.ts index 2e917fb96bcb..836e82bc67dc 100644 --- a/app/client/src/workers/Evaluation/handlers/evalTrigger.ts +++ b/app/client/src/workers/Evaluation/handlers/evalTrigger.ts @@ -22,6 +22,9 @@ export default async function (request: EvalWorkerASyncRequest) { const { evalOrder, unEvalUpdates } = dataTreeEvaluator.setupUpdateTree( unEvalTree.unEvalTree, unEvalTree.configTree, + undefined, + //TODO: the evalTrigger can be optimised to not diff all JS actions + { isAllAffected: true, ids: [] }, ); dataTreeEvaluator.evalAndValidateSubTree( diff --git a/app/client/src/workers/Evaluation/types.ts b/app/client/src/workers/Evaluation/types.ts index 6ac92e7e394d..c93e566dca32 100644 --- a/app/client/src/workers/Evaluation/types.ts +++ b/app/client/src/workers/Evaluation/types.ts @@ -16,6 +16,7 @@ import type { WorkerRequest } from "@appsmith/workers/common/types"; import type { DataTreeDiff } from "@appsmith/workers/Evaluation/evaluationUtils"; import type { APP_MODE } from "entities/App"; import type { WebworkerSpanData } from "UITelemetry/generateWebWorkerTraces"; +import type { AffectedJSObjects } from "sagas/EvaluationsSagaUtils"; export type EvalWorkerSyncRequest = WorkerRequest< T, @@ -41,6 +42,7 @@ export interface EvalTreeRequestData { appMode?: APP_MODE; widgetsMeta: Record; shouldRespondWithLogs?: boolean; + affectedJSObjects: AffectedJSObjects; } export interface EvalTreeResponseData { diff --git a/app/client/src/workers/common/DataTreeEvaluator/index.ts b/app/client/src/workers/common/DataTreeEvaluator/index.ts index c8c9f5c23356..db9350e234d0 100644 --- a/app/client/src/workers/common/DataTreeEvaluator/index.ts +++ b/app/client/src/workers/common/DataTreeEvaluator/index.ts @@ -112,7 +112,11 @@ import { parseJSActions, updateEvalTreeWithJSCollectionState, } from "workers/Evaluation/JSObject"; -import { getFixedTimeDifference, replaceThisDotParams } from "./utils"; +import { + getFixedTimeDifference, + getOnlyAffectedJSObjects, + replaceThisDotParams, +} from "./utils"; import { isJSObjectFunction } from "workers/Evaluation/JSObject/utils"; import { validateActionProperty, @@ -133,6 +137,7 @@ import { profileFn, type WebworkerSpanData, } from "UITelemetry/generateWebWorkerTraces"; +import type { AffectedJSObjects } from "sagas/EvaluationsSagaUtils"; type SortedDependencies = Array; export interface EvalProps { @@ -484,6 +489,7 @@ export default class DataTreeEvaluator { unEvalTree: any, configTree: ConfigTree, webworkerTelemetry: Record = {}, + affectedJSObjects: AffectedJSObjects = { isAllAffected: false, ids: [] }, ): { unEvalUpdates: DataTreeDiff[]; evalOrder: string[]; @@ -512,8 +518,16 @@ export default class DataTreeEvaluator { webworkerTelemetry, () => convertMicroDiffToDeepDiff( - microDiff(oldUnEvalTreeJSCollections, localUnEvalTreeJSCollection) || - [], + microDiff( + getOnlyAffectedJSObjects( + oldUnEvalTreeJSCollections, + affectedJSObjects, + ), + getOnlyAffectedJSObjects( + localUnEvalTreeJSCollection, + affectedJSObjects, + ), + ) || [], ), ); @@ -583,6 +597,7 @@ export default class DataTreeEvaluator { isNewWidgetAdded: false, }; } + DataStore.update(differences); let isNewWidgetAdded = false; diff --git a/app/client/src/workers/common/DataTreeEvaluator/utils.test.ts b/app/client/src/workers/common/DataTreeEvaluator/utils.test.ts new file mode 100644 index 000000000000..3843ebce40d7 --- /dev/null +++ b/app/client/src/workers/common/DataTreeEvaluator/utils.test.ts @@ -0,0 +1,46 @@ +import type { JSActionEntity } from "@appsmith/entities/DataTree/types"; +import { ENTITY_TYPE } from "@appsmith/entities/DataTree/types"; +import { getOnlyAffectedJSObjects } from "./utils"; + +describe("getOnlyAffectedJSObjects", () => { + const dataTree = { + JSObject1: { + actionId: "1234", + variables: ["var", "var2"], + ENTITY_TYPE: ENTITY_TYPE.JSACTION, + }, + JSObject2: { + actionId: "5678", + variables: ["var", "var2"], + ENTITY_TYPE: ENTITY_TYPE.JSACTION, + }, + } as Record; + test("should return only the affected JS Objects when the ids collection is provided ", () => { + const result = getOnlyAffectedJSObjects(dataTree, { + ids: ["1234"], + isAllAffected: false, + }); + expect(result).toEqual({ + JSObject1: { + actionId: "1234", + variables: ["var", "var2"], + ENTITY_TYPE: ENTITY_TYPE.JSACTION, + }, + }); + }); + test("should return the entire tree when isAllAffected is set to true ", () => { + const result = getOnlyAffectedJSObjects(dataTree, { + isAllAffected: true, + ids: [], + }); + expect(result).toEqual(dataTree); + }); + + test("should return nothing when there is no matching action Id", () => { + const result = getOnlyAffectedJSObjects(dataTree, { + ids: ["someInvalidId"], + isAllAffected: false, + }); + expect(result).toEqual({}); + }); +}); diff --git a/app/client/src/workers/common/DataTreeEvaluator/utils.ts b/app/client/src/workers/common/DataTreeEvaluator/utils.ts index 16b9fdc71ea7..2c2274c62c7a 100644 --- a/app/client/src/workers/common/DataTreeEvaluator/utils.ts +++ b/app/client/src/workers/common/DataTreeEvaluator/utils.ts @@ -13,8 +13,10 @@ import type { DataTreeEntity } from "entities/DataTree/dataTreeTypes"; import type { DataTreeEntityConfig, DataTreeEntityObject, + JSActionEntity, } from "@appsmith/entities/DataTree/types"; import { isObject } from "lodash"; +import type { AffectedJSObjects } from "sagas/EvaluationsSagaUtils"; export function getFixedTimeDifference(endTime: number, startTime: number) { return (endTime - startTime).toFixed(2) + " ms"; @@ -81,3 +83,30 @@ export function getValidEntityType( } return !!entityType ? entityType : "noop"; } + +// in this function we are filtering out only the JSObjects that are affected by the changes +// through this we limit the number of JSObjects that are diffed +export function getOnlyAffectedJSObjects( + jsDataTree: Record, + affectedJSObjects: AffectedJSObjects, +) { + const { ids, isAllAffected } = affectedJSObjects; + if (isAllAffected) { + return jsDataTree; + } + if (!ids || ids.length === 0) { + return {}; + } + const idsSet = new Set(ids); + return Object.keys(jsDataTree).reduce( + (acc, key) => { + const { actionId } = jsDataTree[key]; + //only matching action id will be included in the reduced jsDataTree + if (idsSet.has(actionId)) { + acc[key] = jsDataTree[key]; + } + return acc; + }, + {} as Record, + ); +}