chore: send diff updates from worker (#24933)

## Description

- Optimisation around evaluation updates to the state
- Updates generation logic moved from main thread to worker thread
- The diff between previous state and next state is less exacting to
limit the number of updates
- Logic to compress similar updates to reduce the diff updates sent from
worker thread to main thread
- Memoisation fixes and some selector optimisation for improved
performance.

#### PR fixes following issue(s)
Fixes #24866

#### Type of change
- Chore (housekeeping or task changes that don't impact user perception)

## Testing
>
#### How Has This Been Tested?
> Please describe the tests that you ran to verify your changes. Also
list any relevant details for your test configuration.
> Delete anything that is not relevant
- [x] Manual
- [x] Jest
- [ ] Cypress
>
>
#### Test Plan
> Add Testsmith test cases links that relate to this PR
>
>
#### Issues raised during DP testing
> Link issues raised during DP testing for better visiblity and tracking
(copy link from comments dropped on this PR)
>
>
>
## Checklist:
#### Dev activity
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


#### QA activity:
- [ ] [Speedbreak
features](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#speedbreakers-)
have been covered
- [ ] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#areas-of-interest-)
- [ ] Test plan has been peer reviewed by project stakeholders and other
QA members
- [ ] Manually tested functionality on DP
- [ ] We had an implementation alignment call with stakeholders post QA
Round 2
- [ ] Cypress test cases have been added and approved by SDET/manual QA
- [ ] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed
This commit is contained in:
Vemparala Surya Vamsi 2023-08-16 11:04:32 +05:30 committed by GitHub
parent a4f9f05380
commit ec922d1366
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1115 additions and 361 deletions

View File

@ -155,7 +155,7 @@ describe("Validate Mongo URI CRUD with JSON Form", () => {
table.OpenNFilterTable("title", "contains", "USB");
for (let i = 0; i < 3; i++) {
table.ReadTableRowColumnData(i, 5, "v1").then(($cellData) => {
table.ReadTableRowColumnData(i, 6, "v1").then(($cellData) => {
expect($cellData).contains("USB");
});
}

View File

@ -4,11 +4,10 @@ import {
ReduxActionTypes,
} from "@appsmith/constants/ReduxActionConstants";
import { intersection, union } from "lodash";
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import type { DependencyMap } from "utils/DynamicBindingUtils";
import type { Diff } from "deep-diff";
import type { QueryActionConfig } from "entities/Action";
import type { DatasourceConfiguration } from "entities/Datasource";
import type { DiffWithReferenceState } from "workers/Evaluation/helpers";
export const FIRST_EVAL_REDUX_ACTIONS = [
// Pages
@ -158,8 +157,8 @@ export function shouldLog(action: ReduxAction<unknown>) {
}
export const setEvaluatedTree = (
updates: Diff<DataTree, DataTree>[],
): ReduxAction<{ updates: Diff<DataTree, DataTree>[] }> => {
updates: DiffWithReferenceState[],
): ReduxAction<{ updates: DiffWithReferenceState[] }> => {
return {
type: ReduxActionTypes.SET_EVALUATED_TREE,
payload: { updates },

View File

@ -1,5 +1,8 @@
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import { makeEntityConfigsAsObjProperties } from "@appsmith/workers/Evaluation/dataTreeUtils";
import { smallDataSet } from "workers/Evaluation/__tests__/generateOpimisedUpdates.test";
import produce from "immer";
import { cloneDeep } from "lodash";
const unevalTreeFromMainThread = {
Api2: {
@ -143,11 +146,215 @@ const unevalTreeFromMainThread = {
};
describe("7. Test util methods", () => {
it("3. makeDataTreeEntityConfigAsProperty method", () => {
const dataTree = makeEntityConfigsAsObjProperties(
unevalTreeFromMainThread as unknown as DataTree,
);
describe("makeDataTreeEntityConfigAsProperty", () => {
it("should not introduce __evaluation__ property", () => {
const dataTree = makeEntityConfigsAsObjProperties(
unevalTreeFromMainThread as unknown as DataTree,
);
expect(dataTree.Api2).not.toHaveProperty("__evaluation__");
expect(dataTree.Api2).not.toHaveProperty("__evaluation__");
});
describe("identicalEvalPathsPatches decompress updates", () => {
it("should decompress identicalEvalPathsPatches updates into evalProps and state", () => {
const state = {
Table1: {
filteredTableData: smallDataSet,
selectedRows: [],
pageSize: 0,
__evaluation__: {
evaluatedValues: {},
},
},
} as any;
const identicalEvalPathsPatches = {
"Table1.__evaluation__.evaluatedValues.['filteredTableData']":
"Table1.filteredTableData",
};
const evalProps = {
Table1: {
__evaluation__: {
evaluatedValues: {
someProp: "abc",
},
},
},
} as any;
const dataTree = makeEntityConfigsAsObjProperties(state as any, {
sanitizeDataTree: true,
evalProps,
identicalEvalPathsPatches,
});
const expectedState = produce(state, (draft: any) => {
draft.Table1.__evaluation__.evaluatedValues.someProp = "abc";
draft.Table1.__evaluation__.evaluatedValues.filteredTableData =
smallDataSet;
});
expect(dataTree).toEqual(expectedState);
//evalProps should have decompressed updates in it coming from identicalEvalPathsPatches
const expectedEvalProps = produce(evalProps, (draft: any) => {
draft.Table1.__evaluation__.evaluatedValues.filteredTableData =
smallDataSet;
});
expect(evalProps).toEqual(expectedEvalProps);
});
it("should not make any updates to evalProps when the identicalEvalPathsPatches is empty", () => {
const state = {
Table1: {
filteredTableData: smallDataSet,
selectedRows: [],
pageSize: 0,
__evaluation__: {
evaluatedValues: {},
},
},
} as any;
const identicalEvalPathsPatches = {};
const initialEvalProps = {} as any;
const evalProps = cloneDeep(initialEvalProps);
const dataTree = makeEntityConfigsAsObjProperties(state, {
sanitizeDataTree: true,
evalProps,
identicalEvalPathsPatches,
});
expect(dataTree).toEqual(dataTree);
//evalProps not be mutated with any updates
expect(evalProps).toEqual(initialEvalProps);
});
it("should ignore non relevant identicalEvalPathsPatches updates into evalProps and state", () => {
const state = {
Table1: {
filteredTableData: smallDataSet,
selectedRows: [],
pageSize: 0,
__evaluation__: {
evaluatedValues: {},
},
},
} as any;
//ignore non existent widget state
const identicalEvalPathsPatches = {
"SomeWidget.__evaluation__.evaluatedValues.['filteredTableData']":
"SomeWidget.filteredTableData",
};
const initialEvalProps = {
Table1: {
__evaluation__: {
evaluatedValues: {
someProp: "abc",
},
},
},
} as any;
const evalProps = cloneDeep(initialEvalProps);
const dataTree = makeEntityConfigsAsObjProperties(state, {
sanitizeDataTree: true,
evalProps,
identicalEvalPathsPatches,
});
const expectedState = produce(state, (draft: any) => {
draft.Table1.__evaluation__.evaluatedValues.someProp = "abc";
});
expect(dataTree).toEqual(expectedState);
//evalProps not be mutated with any updates
expect(evalProps).toEqual(initialEvalProps);
});
});
describe("serialise", () => {
it("should clean out all functions in the generated state", () => {
const state = {
Table1: {
filteredTableData: smallDataSet,
selectedRows: [],
// eslint-disable-next-line @typescript-eslint/no-empty-function
someFn: () => {},
pageSize: 0,
__evaluation__: {
evaluatedValues: {},
},
},
} as any;
const identicalEvalPathsPatches = {
"Table1.__evaluation__.evaluatedValues.['filteredTableData']":
"Table1.filteredTableData",
};
const evalProps = {
Table1: {
__evaluation__: {
evaluatedValues: {
someProp: "abc",
// eslint-disable-next-line @typescript-eslint/no-empty-function
someEvalFn: () => {},
},
},
},
} as any;
const dataTree = makeEntityConfigsAsObjProperties(state, {
sanitizeDataTree: true,
evalProps,
identicalEvalPathsPatches,
}) as any;
const expectedState = produce(state, (draft: any) => {
draft.Table1.__evaluation__.evaluatedValues.someProp = "abc";
delete draft.Table1.someFn;
draft.Table1.__evaluation__.evaluatedValues.filteredTableData =
smallDataSet;
});
expect(dataTree).toEqual(expectedState);
//function introduced by evalProps is cleaned out
expect(
dataTree.Table1.__evaluation__.evaluatedValues.someEvalFn,
).toBeUndefined();
});
it("should serialise bigInteger values", () => {
const someBigInt = BigInt(121221);
const state = {
Table1: {
pageSize: someBigInt,
__evaluation__: {
evaluatedValues: {},
},
},
} as any;
const identicalEvalPathsPatches = {
"Table1.__evaluation__.evaluatedValues.['pageSize']":
"Table1.pageSize",
};
const evalProps = {
Table1: {
__evaluation__: {
evaluatedValues: {
someProp: someBigInt,
},
},
},
} as any;
const dataTree = makeEntityConfigsAsObjProperties(state, {
sanitizeDataTree: true,
evalProps,
identicalEvalPathsPatches,
});
const expectedState = produce(state, (draft: any) => {
draft.Table1.pageSize = "121221";
draft.Table1.__evaluation__.evaluatedValues.pageSize = "121221";
draft.Table1.__evaluation__.evaluatedValues.someProp = "121221";
});
expect(dataTree).toEqual(expectedState);
});
});
});
});

View File

@ -1,7 +1,7 @@
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import { set } from "lodash";
import { get, set, unset } from "lodash";
import type { EvalProps } from "workers/common/DataTreeEvaluator";
import { removeFunctions } from "@appsmith/workers/Evaluation/evaluationUtils";
import { removeFunctionsAndSerialzeBigInt } from "@appsmith/workers/Evaluation/evaluationUtils";
/**
* This method loops through each entity object of dataTree and sets the entity config from prototype as object properties.
@ -12,21 +12,61 @@ export function makeEntityConfigsAsObjProperties(
option = {} as {
sanitizeDataTree?: boolean;
evalProps?: EvalProps;
identicalEvalPathsPatches?: Record<string, string>;
},
): DataTree {
const { evalProps, sanitizeDataTree = true } = option;
const {
evalProps,
identicalEvalPathsPatches,
sanitizeDataTree = true,
} = option;
const newDataTree: DataTree = {};
for (const entityName of Object.keys(dataTree)) {
const entity = dataTree[entityName];
newDataTree[entityName] = Object.assign({}, entity);
}
const dataTreeToReturn = sanitizeDataTree
? JSON.parse(JSON.stringify(newDataTree))
? removeFunctionsAndSerialzeBigInt(newDataTree)
: newDataTree;
if (!evalProps) return dataTreeToReturn;
const sanitizedEvalProps = removeFunctions(evalProps) as EvalProps;
//clean up deletes widget states
Object.entries(identicalEvalPathsPatches || {}).forEach(
([evalPath, statePath]) => {
const [entity] = statePath.split(".");
if (!dataTreeToReturn[entity]) {
delete identicalEvalPathsPatches?.[evalPath];
}
},
);
// decompressIdenticalEvalPaths
Object.entries(identicalEvalPathsPatches || {}).forEach(
([evalPath, statePath]) => {
const referencePathValue = get(dataTreeToReturn, statePath);
set(evalProps, evalPath, referencePathValue);
},
);
const alreadySanitisedDataSet = {} as EvalProps;
Object.keys(identicalEvalPathsPatches || {}).forEach((evalPath) => {
const val = get(evalProps, evalPath);
//serialised already
alreadySanitisedDataSet[evalPath] = val;
//we are seperating it from evalProps because we don't want to serialise this identical data unecessarily again
unset(evalProps, evalPath);
});
const sanitizedEvalProps = removeFunctionsAndSerialzeBigInt(
evalProps,
) as EvalProps;
Object.entries(alreadySanitisedDataSet).forEach(([path, val]) => {
// add it to sanitised Eval props
set(sanitizedEvalProps, path, val);
//restore it to evalProps
set(evalProps, path, val);
});
for (const [entityName, entityEvalProps] of Object.entries(
sanitizedEvalProps,
)) {

View File

@ -418,17 +418,17 @@ export function isDataTreeEntity(entity: unknown) {
return !!entity && typeof entity === "object" && "ENTITY_TYPE" in entity;
}
export const removeFunctionsAndSerialzeBigInt = (value: any) =>
JSON.parse(
JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v)),
);
// We need to remove functions from data tree to avoid any unexpected identifier while JSON parsing
// Check issue https://github.com/appsmithorg/appsmith/issues/719
export const removeFunctions = (value: any) => {
if (_.isFunction(value)) {
return "Function call";
} else if (_.isObject(value)) {
return JSON.parse(
JSON.stringify(value, (_, v) =>
typeof v === "bigint" ? v.toString() : v,
),
);
return removeFunctionsAndSerialzeBigInt(value);
} else {
return value;
}

View File

@ -199,4 +199,4 @@ function Description(props: Props) {
);
}
export default Description;
export default React.memo(Description);

View File

@ -35,7 +35,6 @@ import type { AppState } from "@appsmith/reducers";
import { getCurrentAppWorkspace } from "@appsmith/selectors/workspaceSelectors";
import { importRemixIcon } from "design-system-old";
import AnalyticsUtil from "utils/AnalyticsUtil";
const AddLineIcon = importRemixIcon(
() => import("remixicon-react/AddLineIcon"),
);
@ -44,12 +43,13 @@ export const useFilteredFileOperations = (query = "") => {
const { appWideDS = [], otherDS = [] } = useAppWideAndOtherDatasource();
const recentDatasourceIds = useSelector(getRecentDatasourceIds);
// helper map for sorting based on recent usage
const recentlyUsedOrderMap = recentDatasourceIds.reduce(
(map: Record<string, number>, id, index) => {
map[id] = index;
return map;
},
{},
const recentlyUsedOrderMap = useMemo(
() =>
recentDatasourceIds.reduce((map: Record<string, number>, id, index) => {
map[id] = index;
return map;
}, {}),
[recentDatasourceIds],
);
/**
* Work around to get the rest api cloud image.
@ -89,7 +89,15 @@ export const useFilteredFileOperations = (query = "") => {
canCreateDatasource,
pagePermissions,
),
[query, appWideDS, otherDS],
[
appWideDS,
canCreateActions,
canCreateDatasource,
otherDS,
pagePermissions,
query,
recentlyUsedOrderMap,
],
);
};
@ -198,8 +206,8 @@ export const getFilteredAndSortedFileOperations = (
export const useFilteredWidgets = (query: string) => {
const allWidgets = useSelector(getAllPageWidgets);
const pages = useSelector(getPageList) || [];
const pageMap = keyBy(pages, "pageId");
const pages = useSelector(getPageList);
const pageMap = useMemo(() => keyBy(pages || [], "pageId"), [pages]);
const searchableWidgets = useMemo(
() =>
allWidgets.filter(
@ -218,7 +226,7 @@ export const useFilteredWidgets = (query: string) => {
return isWidgetNameMatching || isPageNameMatching;
});
}, [allWidgets, query, pages]);
}, [query, searchableWidgets, pageMap]);
};
export const useFilteredActions = (query: string) => {
@ -234,7 +242,7 @@ export const useFilteredActions = (query: string) => {
return isActionNameMatching || isPageNameMatching;
});
}, [actions, query, pages]);
}, [query, actions, pageMap]);
};
export const useFilteredJSCollections = (query: string) => {
@ -252,12 +260,14 @@ export const useFilteredJSCollections = (query: string) => {
return isActionNameMatching || isPageNameMatching;
});
}, [jsActions, query, pages]);
}, [query, jsActions, pageMap]);
};
export const useFilteredPages = (query: string) => {
const pages = useSelector(getPageList) || [];
const pages = useSelector(getPageList);
return useMemo(() => {
if (!pages) return [];
if (!query) return attachKind(pages, SEARCH_ITEM_TYPES.page);
return attachKind(
pages.filter(

View File

@ -71,4 +71,4 @@ function ResultsNotFound() {
);
}
export default ResultsNotFound;
export default React.memo(ResultsNotFound);

View File

@ -116,7 +116,7 @@ function SearchBox({ category, query, setCategory, setQuery }: SearchBoxProps) {
setQuery(query);
(document.querySelector("#global-search") as HTMLInputElement)?.focus();
},
[listenToChange],
[listenToChange, setQuery],
);
return (
@ -162,4 +162,4 @@ function SearchBox({ category, query, setCategory, setQuery }: SearchBoxProps) {
);
}
export default SearchBox;
export default React.memo(SearchBox);

View File

@ -477,4 +477,4 @@ function SearchResults({
);
}
export default SearchResults;
export default React.memo(SearchResults);

View File

@ -5,7 +5,7 @@ import React, {
useRef,
useState,
} from "react";
import { useDispatch, useSelector } from "react-redux";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import styled, { ThemeProvider } from "styled-components";
import { useParams } from "react-router";
import history, { NavigationMethod } from "utils/history";
@ -140,7 +140,7 @@ const getSortedResults = (
};
const filterCategoryList = getFilterCategoryList();
const emptyObj = {};
function GlobalSearch() {
const currentPageId = useSelector(getCurrentPageId) as string;
const modalOpen = useSelector(isModalOpenSelector);
@ -160,7 +160,7 @@ function GlobalSearch() {
(category: SearchCategory) => {
dispatch(setGlobalSearchFilterContext({ category: category }));
},
[dispatch, setGlobalSearchFilterContext],
[dispatch],
);
const params = useParams<ExplorerURLParams>();
@ -197,29 +197,32 @@ function GlobalSearch() {
return state.entities.datasources.list.filter(
(datasource) => datasource.id !== TEMP_DATASOURCE_ID,
);
});
}, shallowEqual);
const datasourcesList = useMemo(() => {
return reducerDatasources.map((datasource) => ({
...datasource,
pageId: params?.pageId,
}));
}, [reducerDatasources]);
}, [params?.pageId, reducerDatasources]);
const filteredDatasources = useMemo(() => {
if (!query) return datasourcesList;
return datasourcesList.filter((datasource) =>
isMatching(datasource.name, query),
);
}, [reducerDatasources, query]);
}, [datasourcesList, query]);
const recentEntities = useRecentEntities();
const recentEntityIds = recentEntities
.map((r) => getEntityId(r))
.filter(Boolean);
const recentEntityIndex = (entity: any) => {
const id =
entity.id || entity.widgetId || entity.config?.id || entity.pageId;
return recentEntityIds.indexOf(id);
};
const recentEntityIndex = useCallback(
(entity: any) => {
const id =
entity.id || entity.widgetId || entity.config?.id || entity.pageId;
return recentEntityIds.indexOf(id);
},
[recentEntityIds],
);
const resetSearchQuery = useSelector(searchQuerySelector);
const lastSelectedWidgetId = useSelector(getLastSelectedWidget);
@ -276,16 +279,20 @@ function GlobalSearch() {
currentPageId,
);
}, [
filteredWidgets,
category,
currentPageId,
filteredActions,
filteredJSCollections,
filteredDatasources,
filteredFileOperations,
filteredJSCollections,
filteredPages,
filteredWidgets,
query,
recentEntities,
recentEntityIndex,
]);
const activeItem = useMemo(() => {
return searchResults[activeItemIndex] || {};
return searchResults[activeItemIndex] || emptyObj;
}, [searchResults, activeItemIndex]);
const getNextActiveItem = (nextIndex: number) => {

View File

@ -11,10 +11,11 @@ import { get } from "lodash";
import type { JSCollectionData } from "reducers/entityReducers/jsActionsReducer";
import { FocusEntity } from "navigation/FocusEntity";
import type { DataTreeEntityObject } from "entities/DataTree/dataTreeFactory";
import { useMemo } from "react";
const recentEntitiesSelector = (state: AppState) =>
state.ui.globalSearch.recentEntities || [];
const emptyArr: any = [];
const useResentEntities = (): Array<
DataTreeEntityObject & {
entityType: FocusEntity;
@ -28,50 +29,56 @@ const useResentEntities = (): Array<
return state.entities.datasources.list;
});
const pages = useSelector(getPageList) || [];
return (recentEntities || [])
.map((entity) => {
const { id, pageId, type } = entity;
if (type === FocusEntity.PAGE) {
const result = pages.find((page) => page.pageId === id);
if (result) {
return {
...result,
entityType: type,
kind: SEARCH_ITEM_TYPES.page,
};
} else {
return null;
}
} else if (type === FocusEntity.DATASOURCE) {
const datasource = reducerDatasources.find(
(reducerDatasource) => reducerDatasource.id === id,
);
return (
datasource && {
...datasource,
entityType: type,
pageId,
const pages = useSelector(getPageList);
const result = useMemo(
() =>
(recentEntities || emptyArr)
.map((entity) => {
const { id, pageId, type } = entity;
if (type === FocusEntity.PAGE) {
if (!pages) return null;
const result = pages.find((page) => page.pageId === id);
if (result) {
return {
...result,
entityType: type,
kind: SEARCH_ITEM_TYPES.page,
};
} else {
return null;
}
} else if (type === FocusEntity.DATASOURCE) {
const datasource = reducerDatasources.find(
(reducerDatasource) => reducerDatasource.id === id,
);
return (
datasource && {
...datasource,
entityType: type,
pageId,
}
);
} else if (type === FocusEntity.API || type === FocusEntity.QUERY)
return {
...actions.find((action) => action?.config?.id === id),
entityType: type,
};
else if (type === FocusEntity.JS_OBJECT)
return {
...jsActions.find(
(action: JSCollectionData) => action?.config?.id === id,
),
entityType: type,
};
else if (type === FocusEntity.PROPERTY_PANE) {
return { ...get(widgetsMap, id, null), entityType: type };
}
);
} else if (type === FocusEntity.API || type === FocusEntity.QUERY)
return {
...actions.find((action) => action?.config?.id === id),
entityType: type,
};
else if (type === FocusEntity.JS_OBJECT)
return {
...jsActions.find(
(action: JSCollectionData) => action?.config?.id === id,
),
entityType: type,
};
else if (type === FocusEntity.PROPERTY_PANE) {
return { ...get(widgetsMap, id, null), entityType: type };
}
})
.filter(Boolean);
})
.filter(Boolean),
[recentEntities, actions, jsActions, pages, reducerDatasources, widgetsMap],
);
return result;
};
export default useResentEntities;

View File

@ -15,7 +15,6 @@ import useHorizontalResize from "utils/hooks/useHorizontalResize";
import { getIsDraggingForSelection } from "selectors/canvasSelectors";
import MultiSelectPropertyPane from "pages/Editor/MultiSelectPropertyPane";
import { getIsDraggingOrResizing } from "selectors/widgetSelectors";
import equal from "fast-deep-equal";
import { selectedWidgetsPresentInCanvas } from "selectors/propertyPaneSelectors";
import { getIsAppSettingsPaneOpen } from "selectors/appSettingsPaneSelectors";
import AppSettingsPane from "pages/Editor/AppSettingsPane";
@ -72,7 +71,9 @@ export const PropertyPaneSidebar = memo((props: Props) => {
const keepThemeWhileDragging =
prevSelectedWidgetId.current === undefined && shouldNotRenderPane;
const selectedWidgets = useSelector(selectedWidgetsPresentInCanvas, equal);
const selectedWidgetsLength = useSelector(
(state) => selectedWidgetsPresentInCanvas(state).length,
);
const isDraggingForSelection = useSelector(getIsDraggingForSelection);
@ -96,19 +97,19 @@ export const PropertyPaneSidebar = memo((props: Props) => {
switch (true) {
case isAppSettingsPaneOpen:
return <AppSettingsPane />;
case selectedWidgets.length > 1:
case selectedWidgetsLength > 1:
return <MultiSelectPropertyPane />;
case selectedWidgets.length === 1:
case selectedWidgetsLength === 1:
if (shouldNotRenderPane) return <CanvasPropertyPane />;
else return <WidgetPropertyPane />;
case selectedWidgets.length === 0:
case selectedWidgetsLength === 0:
return <CanvasPropertyPane />;
default:
return <CanvasPropertyPane />;
}
}, [
isAppSettingsPaneOpen,
selectedWidgets.length,
selectedWidgetsLength,
isDraggingForSelection,
shouldNotRenderPane,
keepThemeWhileDragging,

View File

@ -24,6 +24,7 @@ export type ValidationResponse = {
parsed: any;
messages?: Array<Error>;
transformed?: any;
isParsedValueTheSame?: boolean;
};
export type Validator = (

View File

@ -1,11 +1,9 @@
import type { MutableRefObject } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useEffect, useMemo } from "react";
import { useSelector } from "react-redux";
import type { AppState } from "@appsmith/reducers";
import { compact, get, groupBy } from "lodash";
import type { Datasource } from "entities/Datasource";
import { isStoredDatasource } from "entities/Action";
import { debounce } from "lodash";
import type { WidgetProps } from "widgets/BaseWidget";
import log from "loglevel";
import produce from "immer";
@ -39,13 +37,6 @@ const findWidgets = (widgets: CanvasStructure, keyword: string) => {
}
};
const findDataSources = (dataSources: Datasource[], keyword: string) => {
return dataSources.filter(
(dataSource: Datasource) =>
dataSource.name.toLowerCase().indexOf(keyword.toLowerCase()) > -1,
);
};
export const useDatasourcesPageMapInCurrentApplication = () => {
const actions = useActions();
const reducerDatasources = useSelector((state: AppState) => {
@ -76,56 +67,72 @@ export const useDatasourcesPageMapInCurrentApplication = () => {
export const useCurrentApplicationDatasource = () => {
const actions = useSelector(getActions);
const allDatasources = useSelector(getDatasources);
const datasourceIdsUsedInCurrentApplication = actions.reduce(
(acc, action: ActionData) => {
if (
isStoredDatasource(action.config.datasource) &&
action.config.datasource.id
) {
acc.add(action.config.datasource.id);
}
return acc;
},
new Set(),
);
return allDatasources.filter((ds) =>
datasourceIdsUsedInCurrentApplication.has(ds.id),
);
const currentApplicationDatasource = useMemo(() => {
const datasourceIdsUsedInCurrentApplication = actions.reduce(
(acc, action: ActionData) => {
if (
isStoredDatasource(action.config.datasource) &&
action.config.datasource.id
) {
acc.add(action.config.datasource.id);
}
return acc;
},
new Set(),
);
return allDatasources.filter((ds) =>
datasourceIdsUsedInCurrentApplication.has(ds.id),
);
}, [actions, allDatasources]);
return currentApplicationDatasource;
};
export const useOtherDatasourcesInWorkspace = () => {
const actions = useSelector(getActions);
const allDatasources = useSelector(getDatasources);
const datasourceIdsUsedInCurrentApplication = actions.reduce(
(acc, action: ActionData) => {
if (
isStoredDatasource(action.config.datasource) &&
action.config.datasource.id
) {
acc.add(action.config.datasource.id);
}
return acc;
},
new Set(),
);
return allDatasources.filter(
(ds) =>
!datasourceIdsUsedInCurrentApplication.has(ds.id) &&
ds.id !== TEMP_DATASOURCE_ID,
);
const otherDatasourcesInWorkspace = useMemo(() => {
const datasourceIdsUsedInCurrentApplication = actions.reduce(
(acc, action: ActionData) => {
if (
isStoredDatasource(action.config.datasource) &&
action.config.datasource.id
) {
acc.add(action.config.datasource.id);
}
return acc;
},
new Set(),
);
return allDatasources.filter(
(ds) =>
!datasourceIdsUsedInCurrentApplication.has(ds.id) &&
ds.id !== TEMP_DATASOURCE_ID,
);
}, [actions, allDatasources]);
return otherDatasourcesInWorkspace;
};
export const useAppWideAndOtherDatasource = () => {
const datasourcesUsedInApplication = useCurrentApplicationDatasource();
const otherDatasourceInWorkspace = useOtherDatasourcesInWorkspace();
const appWideDS = useMemo(
() =>
[...datasourcesUsedInApplication].sort((ds1, ds2) =>
ds1.name?.toLowerCase()?.localeCompare(ds2.name?.toLowerCase()),
),
[datasourcesUsedInApplication],
);
const otherDS = useMemo(
() =>
[...otherDatasourceInWorkspace].sort((ds1, ds2) =>
ds1.name?.toLowerCase()?.localeCompare(ds2.name?.toLowerCase()),
),
[otherDatasourceInWorkspace],
);
return {
appWideDS: datasourcesUsedInApplication.sort((ds1, ds2) =>
ds1.name?.toLowerCase()?.localeCompare(ds2.name?.toLowerCase()),
),
otherDS: otherDatasourceInWorkspace.sort((ds1, ds2) =>
ds1.name?.toLowerCase()?.localeCompare(ds2.name?.toLowerCase()),
),
appWideDS,
otherDS,
};
};
@ -143,69 +150,6 @@ export const useDatasourceSuggestions = () => {
);
};
export const useFilteredDatasources = (searchKeyword?: string) => {
const pageIds = usePageIds(searchKeyword);
const datasources = useDatasourcesPageMapInCurrentApplication();
return useMemo(() => {
if (searchKeyword) {
const start = performance.now();
const filteredDatasources = produce(datasources, (draft) => {
for (const [key, value] of Object.entries(draft)) {
if (pageIds.includes(key)) {
draft[key] = value;
} else {
draft[key] = findDataSources(value, searchKeyword);
}
}
});
log.debug("Filtered datasources in:", performance.now() - start, "ms");
return filteredDatasources;
}
return datasources;
}, [searchKeyword, datasources]);
};
export const useJSCollections = (searchKeyword?: string) => {
const reducerActions = useSelector(
(state: AppState) => state.entities.jsActions,
);
const pageIds = usePageIds(searchKeyword);
const actions = useMemo(() => {
return groupBy(reducerActions, "config.pageId");
}, [reducerActions]);
return useMemo(() => {
if (searchKeyword) {
const start = performance.now();
const filteredActions = produce(actions, (draft) => {
for (const [key, value] of Object.entries(draft)) {
if (pageIds.includes(key)) {
draft[key] = value;
} else {
value.forEach((action, index) => {
const searchMatches =
action.config.name
.toLowerCase()
.indexOf(searchKeyword.toLowerCase()) > -1;
if (searchMatches) {
draft[key][index] = action;
} else {
delete draft[key][index];
}
});
}
draft[key] = draft[key].filter(Boolean);
}
});
log.debug("Filtered actions in:", performance.now() - start, "ms");
return filteredActions;
}
return actions;
}, [searchKeyword, actions]);
};
export const useActions = (searchKeyword?: string) => {
const reducerActions = useSelector(
(state: AppState) => state.entities.actions,
@ -243,7 +187,7 @@ export const useActions = (searchKeyword?: string) => {
return filteredActions;
}
return actions;
}, [searchKeyword, actions]);
}, [searchKeyword, actions, pageIds]);
};
export const useWidgets = (searchKeyword?: string) => {
@ -272,7 +216,7 @@ export const useWidgets = (searchKeyword?: string) => {
return filteredDSLs;
}
return pageCanvasStructures;
}, [searchKeyword, pageCanvasStructures]);
}, [searchKeyword, pageCanvasStructures, pageIds]);
};
export const usePageIds = (searchKeyword?: string) => {
@ -300,50 +244,6 @@ export const usePageIds = (searchKeyword?: string) => {
}, [searchKeyword, pages]);
};
export const useFilteredEntities = (
ref: MutableRefObject<HTMLInputElement | null>,
) => {
const start = performance.now();
const [searchKeyword, setSearchKeyword] = useState<string | null>(null);
const search = debounce((e: any) => {
const keyword = e.target.value;
if (keyword.trim().length > 0) {
setSearchKeyword(keyword);
} else {
setSearchKeyword(null);
}
}, 300);
const event = new Event("cleared");
useEffect(() => {
const el: HTMLInputElement | null = ref.current;
el?.addEventListener("keydown", search);
el?.addEventListener("cleared", search);
return () => {
el?.removeEventListener("keydown", search);
el?.removeEventListener("cleared", search);
};
}, [ref, search]);
const clearSearch = useCallback(() => {
const el: HTMLInputElement | null = ref.current;
if (el && el.value.trim().length > 0) {
el.value = "";
el?.dispatchEvent(event);
}
}, [ref, event]);
const stop = performance.now();
log.debug("Explorer hook props calculations took", stop - start, "ms");
return {
searchKeyword: searchKeyword ?? undefined,
clearSearch,
};
};
export const useEntityUpdateState = (entityId: string) => {
return useSelector((state: AppState) =>
get(state, "ui.explorer.entity.updatingEntity")?.includes(entityId),

View File

@ -108,31 +108,40 @@ const useDependencyList = (name: string) => {
);
const guidedTour = useSelector(inGuidedTour);
const getEntityId = useCallback((name) => {
const entity = dataTree[name];
const getEntityId = useCallback(
(name) => {
const entity = dataTree[name];
if (isWidget(entity)) {
return entity.widgetId;
} else if (isAction(entity)) {
return entity.actionId;
}
}, []);
if (isWidget(entity)) {
return entity.widgetId;
} else if (isAction(entity)) {
return entity.actionId;
}
},
[dataTree],
);
const entityDependencies = useMemo(() => {
if (guidedTour) return null;
return getDependenciesFromInverseDependencies(inverseDependencyMap, name);
}, [name, inverseDependencyMap, guidedTour]);
const dependencyOptions =
entityDependencies?.directDependencies.map((e) => ({
label: e,
value: getEntityId(e) ?? e,
})) ?? [];
const inverseDependencyOptions =
entityDependencies?.inverseDependencies.map((e) => ({
label: e,
value: getEntityId(e),
})) ?? [];
const dependencyOptions = useMemo(
() =>
entityDependencies?.directDependencies.map((e) => ({
label: e,
value: getEntityId(e) ?? e,
})) ?? [],
[entityDependencies?.directDependencies, getEntityId],
);
const inverseDependencyOptions = useMemo(
() =>
entityDependencies?.inverseDependencies.map((e) => ({
label: e,
value: getEntityId(e),
})) ?? [],
[entityDependencies?.inverseDependencies, getEntityId],
);
return {
dependencyOptions,

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback } from "react";
import styled from "styled-components";
import { useDispatch, useSelector } from "react-redux";
@ -37,17 +37,20 @@ export function PropertyPaneTab(props: PropertyPaneTabProps) {
getSelectedPropertyTabIndex(state, props.panelPropertyPath),
);
const setSelectedIndex = (index: number) => {
dispatch(setSelectedPropertyTabIndex(index, props.panelPropertyPath));
};
const setSelectedIndex = useCallback(
(index: number) => {
dispatch(setSelectedPropertyTabIndex(index, props.panelPropertyPath));
},
[dispatch, props.panelPropertyPath],
);
const onValueChange = useCallback(
(value) => {
setSelectedIndex(tabs.indexOf(value) || 0);
},
[setSelectedIndex],
);
return (
<StyledTabs
onValueChange={(value) => {
setSelectedIndex(tabs.indexOf(value) || 0);
}}
value={tabs[selectedIndex]}
>
<StyledTabs onValueChange={onValueChange} value={tabs[selectedIndex]}>
<TabsList>
{props.contentComponent && <Tab value={tabs[0]}>Content</Tab>}
{props.styleComponent && <Tab value={tabs[1]}>Style</Tab>}

View File

@ -16,7 +16,6 @@ import type { WidgetType } from "constants/WidgetConstants";
import { WIDGET_ID_SHOW_WALKTHROUGH } from "constants/WidgetConstants";
import type { InteractionAnalyticsEventDetail } from "utils/AppsmithUtils";
import { INTERACTION_ANALYTICS_EVENT } from "utils/AppsmithUtils";
import { emitInteractionAnalyticsEvent } from "utils/AppsmithUtils";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { buildDeprecationWidgetMessage, isWidgetDeprecated } from "../utils";
import { Button, Callout } from "design-system";
@ -63,7 +62,7 @@ function PropertyPaneView(
} & IPanelProps,
) {
const dispatch = useDispatch();
const { ...panel } = props;
const panel = props;
const widgetProperties = useSelector(
getWidgetPropsForPropertyPaneView,
equal,
@ -77,7 +76,7 @@ function PropertyPaneView(
}
return true;
}, [widgetProperties?.type, excludeList]);
}, [widgetProperties]);
const { searchText, setSearchText } = useSearchText("");
const { pushFeature } = useContext(WalkthroughContext) || {};
const widgets = useSelector(getWidgets);
@ -174,19 +173,6 @@ function PropertyPaneView(
*/
const onCopy = useCallback(() => dispatch(copyWidget(false)), [dispatch]);
const handleTabKeyDownForButton = useCallback(
(propertyName: string) => (e: React.KeyboardEvent) => {
if (e.key === "Tab")
emitInteractionAnalyticsEvent(containerRef?.current, {
key: e.key,
propertyName,
propertyType: "BUTTON",
widgetType: widgetProperties?.type,
});
},
[],
);
/**
* actions shown on the right of title
*/
@ -220,7 +206,7 @@ function PropertyPaneView(
),
},
];
}, [onCopy, onDelete, handleTabKeyDownForButton]);
}, [onCopy, onDelete]);
useEffect(() => {
setSearchText("");

View File

@ -5,6 +5,8 @@ import { applyChange } from "deep-diff";
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import { createImmerReducer } from "utils/ReducerUtils";
import * as Sentry from "@sentry/react";
import { get } from "lodash";
import type { DiffWithReferenceState } from "workers/Evaluation/helpers";
export type EvaluatedTreeState = DataTree;
@ -15,7 +17,7 @@ const evaluatedTreeReducer = createImmerReducer(initialState, {
state: EvaluatedTreeState,
action: ReduxAction<{
dataTree: DataTree;
updates: Diff<DataTree, DataTree>[];
updates: DiffWithReferenceState[];
removedPaths: [string];
}>,
) => {
@ -29,7 +31,20 @@ const evaluatedTreeReducer = createImmerReducer(initialState, {
continue;
}
try {
applyChange(state, undefined, update);
//these are the decompression updates, there are cases where identical values are present in the state
//over here we have the path which has the identical value and apply as an update
if (update.kind === "referenceState") {
const { path, referencePath } = update;
const patch = {
kind: "N",
path,
rhs: get(state, referencePath),
} as Diff<DataTree, DataTree>;
applyChange(state, undefined, patch);
} else {
applyChange(state, undefined, update);
}
} catch (e) {
Sentry.captureException(e, {
extra: {

View File

@ -68,7 +68,6 @@ import {
TriggerKind,
} from "constants/AppsmithActionConstants/ActionConstants";
import { validate } from "workers/Evaluation/validations";
import { diff } from "deep-diff";
import { REPLAY_DELAY } from "entities/Replay/replayUtils";
import type { EvaluationVersion } from "@appsmith/api/ApplicationApi";
@ -103,7 +102,6 @@ import { waitForWidgetConfigBuild } from "./InitSagas";
import { logDynamicTriggerExecution } from "@appsmith/sagas/analyticsSaga";
const APPSMITH_CONFIGS = getAppsmithConfigs();
export const evalWorker = new GracefulWorkerService(
new Worker(
new URL("../workers/Evaluation/evaluation.worker.ts", import.meta.url),
@ -132,7 +130,6 @@ export function* updateDataTreeHandler(
const {
configTree,
dataTree,
dependencies,
errors,
evalMetaUpdates = [],
@ -147,6 +144,7 @@ export function* updateDataTreeHandler(
undefinedEvalValuesMap,
unEvalUpdates,
jsVarsCreatedEvent,
updates,
} = evalTreeResponse;
const appMode: ReturnType<typeof getAppMode> = yield select(getAppMode);
@ -157,14 +155,13 @@ export function* updateDataTreeHandler(
PerformanceTracker.startAsyncTracking(
PerformanceTransactionName.SET_EVALUATED_TREE,
);
const oldDataTree: ReturnType<typeof getDataTree> = yield select(getDataTree);
const updates = diff(oldDataTree, dataTree) || [];
if (!isEmpty(staleMetaIds)) {
yield put(resetWidgetsMetaState(staleMetaIds));
}
yield put(setEvaluatedTree(updates));
ConfigTreeActions.setConfigTree(configTree);
PerformanceTracker.stopAsyncTracking(

View File

@ -72,22 +72,28 @@ const getCurrentWidgetName = createSelector(
export const getWidgetPropsForPropertyPane = createSelector(
getCurrentWidgetProperties,
getCurrentAppPositioningType,
getDataTree,
(state) => {
const currentWidget = getCurrentWidgetProperties(state);
if (!currentWidget) return;
const evaluatedWidget = find(getDataTree(state), {
widgetId: currentWidget.widgetId,
}) as WidgetEntity;
if (!evaluatedWidget) return;
return evaluatedWidget[EVALUATION_PATH];
},
(
widget: WidgetProps | undefined,
appPositioningType,
evaluatedTree: DataTree,
evaluatedValue: any,
): WidgetProps | undefined => {
if (!widget) return undefined;
const evaluatedWidget = find(evaluatedTree, {
widgetId: widget.widgetId,
}) as WidgetEntity;
const widgetProperties = {
...widget,
appPositioningType,
};
if (evaluatedWidget) {
widgetProperties[EVALUATION_PATH] = evaluatedWidget[EVALUATION_PATH];
if (evaluatedValue) {
widgetProperties[EVALUATION_PATH] = evaluatedValue;
}
return widgetProperties;
},

View File

@ -63,6 +63,7 @@ function withWidgetProps(WrappedWidget: typeof BaseWidget) {
type,
widgetId,
} = props;
const isPreviewMode = useSelector(previewModeSelector);
const canvasWidget = useSelector((state: AppState) =>
getWidget(state, widgetId),
@ -82,6 +83,7 @@ function withWidgetProps(WrappedWidget: typeof BaseWidget) {
const evaluatedWidget = useSelector((state: AppState) =>
getWidgetEvalValues(state, widgetName),
);
const isLoading = useSelector((state: AppState) =>
getIsWidgetLoading(state, widgetName),
);
@ -316,7 +318,6 @@ function withWidgetProps(WrappedWidget: typeof BaseWidget) {
: undefined,
};
}
return <WrappedWidget {...widgetProps} />;
}

View File

@ -0,0 +1,248 @@
import produce from "immer";
import { range } from "lodash";
import { generateOptimisedUpdates } from "../helpers";
export const smallDataSet = [
{
address: "Paseo de Soledad Tur 245 Puerta 4 \nValencia, 50285",
company: "Gonzalez Inc",
},
{
address: "Ronda Rosalina Menéndez 72\nCuenca, 38057",
company: "Gallego, Pedrosa and Conesa",
},
{
address: "833 جزيني Gateway\nمشفقland, AL 63852",
company: "اهرام-الحويطات",
},
];
//size of about 300 elements
const largeDataSet = range(100).flatMap(() => smallDataSet) as any;
const oldState = {
Table1: {
ENTITY_TYPE: "WIDGET",
primaryColumns: {
customColumn2: {
isDisabled: false,
},
},
tableData: [],
filteredTableData: smallDataSet,
selectedRows: [],
pageSize: 0,
triggerRowSelection: false,
type: "TABLE_WIDGET_V2",
__evaluation__: {
errors: {
transientTableData: [],
tableData: [],
processedTableData: [],
},
evaluatedValues: {
filteredTableData: smallDataSet,
transientTableData: {},
tableData: [],
processedTableData: [],
"primaryColumns.customColumn2.isDisabled": false,
},
},
},
Select1: {
value: "",
ENTITY_TYPE: "WIDGET",
options: [
{
label: "courtney68@example.net",
value: 1,
},
{
label: "osamusato@example.com",
value: 2,
},
],
type: "SELECT_WIDGET",
__evaluation__: {
errors: {
options: [],
},
evaluatedValues: {
"meta.value": "",
value: "",
},
},
},
};
describe("optimised diff updates", () => {
describe("regular diff", () => {
test("should generate regular diff updates when a simple property changes in the widget property segment", () => {
const newState = produce(oldState, (draft) => {
draft.Table1.pageSize = 17;
});
const updates = generateOptimisedUpdates(oldState, newState);
expect(updates).toEqual([
{ kind: "E", path: ["Table1", "pageSize"], lhs: 0, rhs: 17 },
]);
});
test("should generate regular diff updates when a simple property changes in the __evaluation__ segment ", () => {
const validationError = "Some validation error";
const newState = produce(oldState, (draft) => {
draft.Table1.__evaluation__.evaluatedValues.tableData =
validationError as any;
});
const updates = generateOptimisedUpdates(oldState, newState);
expect(updates).toEqual([
{
kind: "E",
path: ["Table1", "__evaluation__", "evaluatedValues", "tableData"],
lhs: [],
rhs: validationError,
},
]);
});
});
describe("diffs with identicalEvalPathsPatches", () => {
test("should not generate any updates when both the states are the same", () => {
const updates = generateOptimisedUpdates(oldState, oldState);
expect(updates).toEqual([]);
const identicalEvalPathsPatches = {
"Table1.__evaluation__.evaluatedValues.['tableData']":
"Table1.tableData",
};
const updatesWithCompressionMap = generateOptimisedUpdates(
oldState,
oldState,
identicalEvalPathsPatches,
);
expect(updatesWithCompressionMap).toEqual([]);
});
test("should generate the correct table data updates and reference state patches", () => {
const identicalEvalPathsPatches = {
"Table1.__evaluation__.evaluatedValues.['tableData']":
"Table1.tableData",
};
const newState = produce(oldState, (draft) => {
draft.Table1.tableData = largeDataSet;
});
const updates = generateOptimisedUpdates(
oldState,
newState,
identicalEvalPathsPatches,
);
expect(updates).toEqual([
{ kind: "N", path: ["Table1", "tableData"], rhs: largeDataSet },
{
kind: "referenceState",
path: ["Table1", "__evaluation__", "evaluatedValues", "tableData"],
referencePath: "Table1.tableData",
},
]);
});
test("should not generate granular updates and generate a patch which replaces the complete collection when any change is made to the large collection ", () => {
const largeDataSetWithSomeSimpleChange = produce(
largeDataSet,
(draft: any) => {
//making a change to the first row of the collection
draft[0].address = "some new address";
//making a change to the second row of the collection
draft[1].address = "some other new address";
},
) as any;
const identicalEvalPathsPatches = {
"Table1.__evaluation__.evaluatedValues.['tableData']":
"Table1.tableData",
};
const evalVal = "some eval value" as any;
const newState = produce(oldState, (draft) => {
//this value eval value should be ignores since we have provided identicalEvalPathsPatches which takes precedence
draft.Table1.__evaluation__.evaluatedValues.tableData = evalVal as any;
draft.Table1.tableData = largeDataSetWithSomeSimpleChange;
});
const updates = generateOptimisedUpdates(
oldState,
newState,
identicalEvalPathsPatches,
);
//should not see evalVal since identical eval path patches takes precedence
expect(JSON.stringify(updates)).not.toContain(evalVal);
expect(updates).toEqual([
{
kind: "N",
path: ["Table1", "tableData"],
//we should not see granular updates but complete replacement
rhs: largeDataSetWithSomeSimpleChange,
},
{
//compression patch
kind: "referenceState",
path: ["Table1", "__evaluation__", "evaluatedValues", "tableData"],
referencePath: "Table1.tableData",
},
]);
});
test("should not generate compression patches when there are no identical eval paths provided ", () => {
const tableDataEvaluationValue = "someValidation error";
const largeDataSetWithSomeSimpleChange = produce(
largeDataSet,
(draft: any) => {
//making a change to the first row of the collection
draft[0].address = "some new address";
//making a change to the second row of the collection
draft[1].address = "some other new address";
},
) as any;
// empty indentical eval paths
const identicalEvalPathsPatches = {};
const newState = produce(oldState, (draft) => {
draft.Table1.tableData = largeDataSetWithSomeSimpleChange;
draft.Table1.__evaluation__.evaluatedValues.tableData =
tableDataEvaluationValue as any;
});
const updates = generateOptimisedUpdates(
oldState,
newState,
identicalEvalPathsPatches,
);
expect(updates).toEqual([
{
//considered as regular diff since there are no identical eval paths provided
kind: "E",
path: ["Table1", "__evaluation__", "evaluatedValues", "tableData"],
lhs: [],
rhs: tableDataEvaluationValue,
},
{
kind: "N",
path: ["Table1", "tableData"],
//we should not see granular updates but complete replacement
rhs: largeDataSetWithSomeSimpleChange,
},
]);
});
test("should not generate any update when the new state has the same value as the old state", () => {
const oldStateSetWithSomeData = produce(oldState, (draft) => {
draft.Table1.tableData = largeDataSet;
draft.Table1.__evaluation__.evaluatedValues.tableData = largeDataSet;
});
const identicalEvalPathsPatches = {
"Table1.__evaluation__.evaluatedValues.['tableData']":
"Table1.tableData",
};
const newStateSetWithTheSameData = produce(oldState, (draft) => {
//deliberating making a new instance of largeDataSet
draft.Table1.tableData = [...largeDataSet] as any;
});
//since the old state has the same value as the new value..we wont generate a patch unnecessarily...
const updates = generateOptimisedUpdates(
oldStateSetWithSomeData,
newStateSetWithTheSameData,
identicalEvalPathsPatches,
);
expect(updates).toEqual([]);
});
});
});

View File

@ -14,6 +14,7 @@ import { MAIN_THREAD_ACTION } from "@appsmith/workers/Evaluation/evalWorkerActio
import type { UpdateDataTreeMessageData } from "sagas/EvalWorkerActionSagas";
import type { JSUpdate } from "utils/JSPaneUtils";
import { setEvalContext } from "./evaluate";
import { generateOptimisedUpdatesAndSetPrevState } from "./helpers";
export function evalTreeWithChanges(
updatedValuePaths: string[][],
@ -63,6 +64,8 @@ export function evalTreeWithChanges(
dataTree = makeEntityConfigsAsObjProperties(dataTreeEvaluator.evalTree, {
evalProps: dataTreeEvaluator.evalProps,
identicalEvalPathsPatches:
dataTreeEvaluator.getEvalPathsIdenticalToState(),
});
/** Make sure evalMetaUpdates is sanitized to prevent postMessage failure */
@ -75,8 +78,12 @@ export function evalTreeWithChanges(
configTree = dataTreeEvaluator.oldConfigTree;
}
const evalTreeResponse: EvalTreeResponseData = {
const updates = generateOptimisedUpdatesAndSetPrevState(
dataTree,
dataTreeEvaluator,
);
const evalTreeResponse: EvalTreeResponseData = {
updates,
dependencies,
errors,
evalMetaUpdates,

View File

@ -23,6 +23,7 @@ import JSObjectCollection from "workers/Evaluation/JSObject/Collection";
import { setEvalContext } from "../evaluate";
import { getJSVariableCreatedEvents } from "../JSObject/JSVariableEvents";
import { errorModifier } from "../errorModifier";
import { generateOptimisedUpdatesAndSetPrevState } from "../helpers";
export let replayMap: Record<string, ReplayEntity<any>> | undefined;
export let dataTreeEvaluator: DataTreeEvaluator | undefined;
@ -82,6 +83,8 @@ export default function (request: EvalWorkerSyncRequest) {
const dataTreeResponse = dataTreeEvaluator.evalAndValidateFirstTree();
dataTree = makeEntityConfigsAsObjProperties(dataTreeResponse.evalTree, {
evalProps: dataTreeEvaluator.evalProps,
identicalEvalPathsPatches:
dataTreeEvaluator?.getEvalPathsIdenticalToState(),
});
staleMetaIds = dataTreeResponse.staleMetaIds;
} else if (dataTreeEvaluator.hasCyclicalDependency || forceEvaluation) {
@ -122,6 +125,8 @@ export default function (request: EvalWorkerSyncRequest) {
dataTree = makeEntityConfigsAsObjProperties(dataTreeResponse.evalTree, {
evalProps: dataTreeEvaluator.evalProps,
identicalEvalPathsPatches:
dataTreeEvaluator?.getEvalPathsIdenticalToState(),
});
staleMetaIds = dataTreeResponse.staleMetaIds;
} else {
@ -167,6 +172,8 @@ export default function (request: EvalWorkerSyncRequest) {
dataTree = makeEntityConfigsAsObjProperties(dataTreeEvaluator.evalTree, {
evalProps: dataTreeEvaluator.evalProps,
identicalEvalPathsPatches:
dataTreeEvaluator?.getEvalPathsIdenticalToState(),
});
evalMetaUpdates = JSON.parse(
@ -201,6 +208,8 @@ export default function (request: EvalWorkerSyncRequest) {
makeEntityConfigsAsObjProperties(unevalTree, {
sanitizeDataTree: false,
evalProps: dataTreeEvaluator?.evalProps,
identicalEvalPathsPatches:
dataTreeEvaluator?.getEvalPathsIdenticalToState(),
}),
widgetTypeConfigMap,
configTree,
@ -210,8 +219,13 @@ export default function (request: EvalWorkerSyncRequest) {
const jsVarsCreatedEvent = getJSVariableCreatedEvents(jsUpdates);
const evalTreeResponse: EvalTreeResponseData = {
const updates = generateOptimisedUpdatesAndSetPrevState(
dataTree,
dataTreeEvaluator,
);
const evalTreeResponse: EvalTreeResponseData = {
updates,
dependencies,
errors,
evalMetaUpdates,

View File

@ -1,3 +1,18 @@
import type { Diff } from "deep-diff";
import { diff } from "deep-diff";
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import equal from "fast-deep-equal";
import produce from "immer";
import { get, isNumber, isObject, set } from "lodash";
export interface DiffReferenceState {
kind: "referenceState";
path: any[];
referencePath: string;
}
export type DiffWithReferenceState =
| Diff<DataTree, DataTree>
| DiffReferenceState;
// Finds the first index which is a duplicate value
// Returns -1 if there are no duplicates
// Returns the index of the first duplicate entry it finds
@ -51,3 +66,210 @@ export const countOccurrences = (
}
return n;
};
const LARGE_COLLECTION_SIZE = 100;
// for object paths which have a "." in the object key like "a.['b.c']"
const REGEX_NESTED_OBJECT_PATH = /(.+)\.\[\'(.*)\'\]/;
const generateWithKey = (basePath: any, key: any) => {
const segmentedPath = [...basePath, key];
if (isNumber(key)) {
return {
path: basePath.join(".") + ".[" + key + "]",
segmentedPath,
};
}
if (key.includes(".")) {
return {
path: basePath.join(".") + ".['" + key + "']",
segmentedPath,
};
}
return {
path: basePath.join(".") + "." + key,
segmentedPath,
};
};
const isLargeCollection = (val: any) => {
if (!Array.isArray(val)) return false;
const rowSize = !isObject(val[0]) ? 1 : Object.keys(val[0]).length;
const size = val.length * rowSize;
return size > LARGE_COLLECTION_SIZE;
};
const normaliseEvalPath = (identicalEvalPathsPatches: any) =>
Object.keys(identicalEvalPathsPatches || {}).reduce(
(acc: any, evalPath: string) => {
//for object paths which have a "." in the object key like "a.['b.c']", we need to extract these
// paths and break them to appropriate patch paths
const matches = evalPath.match(REGEX_NESTED_OBJECT_PATH);
if (!matches || !matches.length) {
//regular paths like "a.b.c"
acc[evalPath] = identicalEvalPathsPatches[evalPath];
return acc;
}
const [, firstSeg, nestedPathSeg] = matches;
// normalise non nested paths like "a.['b']"
if (!nestedPathSeg.includes(".")) {
const key = [firstSeg, nestedPathSeg].join(".");
acc[key] = identicalEvalPathsPatches[evalPath];
return acc;
}
// object paths which have a "." like "a.['b.c']"
const key = [firstSeg, `['${nestedPathSeg}']`].join(".");
acc[key] = identicalEvalPathsPatches[evalPath];
return acc;
},
{},
);
//completely new updates which the diff will not traverse through needs to be attached
const generateMissingSetPathsUpdates = (
ignoreLargeKeys: any,
ignoreLargeKeysHasBeenAttached: any,
dataTree: any,
): DiffWithReferenceState[] =>
Object.keys(ignoreLargeKeys)
.filter((evalPath) => !ignoreLargeKeysHasBeenAttached.has(evalPath))
.map((evalPath) => {
const statePath = ignoreLargeKeys[evalPath];
//for object paths which have a "." in the object key like "a.['b.c']", we need to extract these
// paths and break them to appropriate patch paths
//get the matching value from the widget properies in the data tree
const val = get(dataTree, statePath);
const matches = evalPath.match(REGEX_NESTED_OBJECT_PATH);
if (!matches || !matches.length) {
//regular paths like "a.b.c"
return {
kind: "N",
path: evalPath.split("."),
rhs: val,
};
}
// object paths which have a "." like "a.['b.c']"
const [, firstSeg, nestedPathSeg] = matches;
const segmentedPath = [...firstSeg.split("."), nestedPathSeg];
return {
kind: "N",
path: segmentedPath,
rhs: val,
};
});
const generateDiffUpdates = (
oldDataTree: any,
dataTree: any,
ignoreLargeKeys: any,
): DiffWithReferenceState[] => {
const attachDirectly: DiffWithReferenceState[] = [];
const ignoreLargeKeysHasBeenAttached = new Set();
const attachLater: DiffWithReferenceState[] = [];
const updates =
diff(oldDataTree, dataTree, (path, key) => {
if (!path.length || key === "__evaluation__") return false;
const { path: setPath, segmentedPath } = generateWithKey(path, key);
// if ignore path is present...this segment of code generates the data compression patches
if (!!ignoreLargeKeys[setPath]) {
const originalStateVal = get(oldDataTree, segmentedPath);
const correspondingStatePath = ignoreLargeKeys[setPath];
const statePathValue = get(dataTree, correspondingStatePath);
if (!equal(originalStateVal, statePathValue)) {
//reference state patches are a patch that does not have a patch value but it provides a path which contains the same value
//this is helpful in making the payload sent to the main thread small
attachLater.push({
kind: "referenceState",
path: segmentedPath,
referencePath: correspondingStatePath,
});
}
ignoreLargeKeysHasBeenAttached.add(setPath);
return true;
}
const rhs = get(dataTree, segmentedPath);
const lhs = get(oldDataTree, segmentedPath);
const isLhsLarge = isLargeCollection(lhs);
const isRhsLarge = isLargeCollection(rhs);
if (!isLhsLarge && !isRhsLarge) {
//perform diff on this node
return false;
}
//if either of values are large just directly attach it don't have to generate very granular updates
if ((!isLhsLarge && isRhsLarge) || (isLhsLarge && !isRhsLarge)) {
attachDirectly.push({ kind: "N", path: segmentedPath, rhs });
return true;
}
//if the values are different attach the update directly
!equal(lhs, rhs) &&
attachDirectly.push({ kind: "N", path: segmentedPath, rhs });
//ignore diff on this node
return true;
}) || [];
const missingSetPaths = generateMissingSetPathsUpdates(
ignoreLargeKeys,
ignoreLargeKeysHasBeenAttached,
dataTree,
);
const largeDataSetUpdates = [
...attachDirectly,
...missingSetPaths,
...attachLater,
];
return [...updates, ...largeDataSetUpdates];
};
export const generateOptimisedUpdates = (
oldDataTree: any,
dataTree: any,
identicalEvalPathsPatches?: Record<string, string>,
): DiffWithReferenceState[] => {
const ignoreLargeKeys = normaliseEvalPath(identicalEvalPathsPatches);
const updates = generateDiffUpdates(oldDataTree, dataTree, ignoreLargeKeys);
return updates;
};
export const decompressIdenticalEvalPaths = (
dataTree: any,
identicalEvalPathsPatches: Record<string, string>,
) =>
produce(dataTree, (draft: any) =>
Object.entries(identicalEvalPathsPatches || {}).forEach(([key, value]) => {
const referencePathValue = get(dataTree, value);
set(draft, key, referencePathValue);
}),
);
export const generateOptimisedUpdatesAndSetPrevState = (
dataTree: any,
dataTreeEvaluator: any,
) => {
const identicalEvalPathsPatches =
dataTreeEvaluator?.getEvalPathsIdenticalToState();
const updates = generateOptimisedUpdates(
dataTreeEvaluator?.getPrevState(),
dataTree,
identicalEvalPathsPatches,
);
dataTreeEvaluator?.setPrevState(dataTree);
return updates;
};

View File

@ -1,6 +1,5 @@
import type {
ConfigTree,
DataTree,
unEvalAndConfigTree,
} from "entities/DataTree/dataTreeFactory";
import type { ActionValidationConfigMap } from "constants/PropertyControlConstants";
@ -19,6 +18,7 @@ import type { EvalMetaUpdates } from "@appsmith/workers/common/DataTreeEvaluator
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 { DiffWithReferenceState } from "./helpers";
export type EvalWorkerSyncRequest = WorkerRequest<any, EVAL_WORKER_SYNC_ACTION>;
export type EvalWorkerASyncRequest = WorkerRequest<
@ -42,7 +42,6 @@ export interface EvalTreeRequestData {
}
export interface EvalTreeResponseData {
dataTree: DataTree;
dependencies: DependencyMap;
errors: EvalError[];
evalMetaUpdates: EvalMetaUpdates;
@ -58,6 +57,7 @@ export interface EvalTreeResponseData {
isNewWidgetAdded: boolean;
undefinedEvalValuesMap: Record<string, boolean>;
jsVarsCreatedEvent?: { path: string; type: string }[];
updates: DiffWithReferenceState[];
}
export type JSVarMutatedEvents = Record<string, { path: string; type: string }>;

View File

@ -113,6 +113,7 @@ import {
import { isJSObjectFunction } from "workers/Evaluation/JSObject/utils";
import {
getValidatedTree,
setToEvalPathsIdenticalToState,
validateActionProperty,
validateAndParseWidgetProperty,
} from "./validationUtils";
@ -164,8 +165,18 @@ export default class DataTreeEvaluator {
* Sanitized eval values and errors
*/
evalProps: EvalProps = {};
//when attaching values to __evaluations__ segment of the state there are cases where this value is identical to the widget property
//in those cases do not it to the dataTree and update this map. The main thread can decompress these updates and we can minimise the data transfer
evalPathsIdenticalToState: any = {};
undefinedEvalValuesMap: Record<string, boolean> = {};
prevState = {};
setPrevState(state: any) {
this.prevState = state;
}
getPrevState() {
return this.prevState;
}
public hasCyclicalDependency = false;
constructor(
widgetConfigMap: WidgetTypeConfigMap,
@ -177,6 +188,9 @@ export default class DataTreeEvaluator {
this.widgetConfigMap = widgetConfigMap;
}
getEvalPathsIdenticalToState(): Record<string, string> {
return this.evalPathsIdenticalToState || {};
}
getEvalTree() {
return this.evalTree;
}
@ -344,6 +358,7 @@ export default class DataTreeEvaluator {
evaluatedTree,
{
evalProps: this.evalProps,
evalPathsIdenticalToState: this.evalPathsIdenticalToState,
},
this.oldConfigTree,
),
@ -1028,6 +1043,7 @@ export default class DataTreeEvaluator {
evalPropertyValue,
unEvalPropertyValue,
evalProps: this.evalProps,
evalPathsIdenticalToState: this.evalPathsIdenticalToState,
});
this.setParsedValue({
@ -1078,13 +1094,19 @@ export default class DataTreeEvaluator {
}
}
set(
this.evalProps,
getEvalValuePath(fullPropertyPath),
evalPropertyValue,
);
set(currentTree, fullPropertyPath, evalPropertyValue);
if (!propertyPath) return currentTree;
const evalPath = getEvalValuePath(fullPropertyPath);
setToEvalPathsIdenticalToState({
evalPath,
evalPathsIdenticalToState: this.evalPathsIdenticalToState,
evalProps: this.evalProps,
isParsedValueTheSame: true,
statePath: fullPropertyPath,
value: evalPropertyValue,
});
set(currentTree, fullPropertyPath, evalPropertyValue);
return currentTree;
}
case ENTITY_TYPE.JSACTION: {
@ -1113,14 +1135,22 @@ export default class DataTreeEvaluator {
!hasUnEvalValueModified && prevEvaluatedValue
? prevEvaluatedValue
: evalPropertyValue;
set(
this.evalProps,
getEvalValuePath(fullPropertyPath, {
isPopulated: true,
fullPath: true,
}),
evalValue,
);
const evalPath = getEvalValuePath(fullPropertyPath, {
isPopulated: true,
//what is the purpose of this argument
fullPath: true,
});
setToEvalPathsIdenticalToState({
evalPath,
evalPathsIdenticalToState: this.evalPathsIdenticalToState,
evalProps: this.evalProps,
isParsedValueTheSame: true,
statePath: fullPropertyPath,
value: evalValue,
});
set(currentTree, fullPropertyPath, evalValue);
JSObjectCollection.setVariableValue(
evalValue,
@ -1461,6 +1491,7 @@ export default class DataTreeEvaluator {
fullPath,
) as unknown as string,
evalProps: this.evalProps,
evalPathsIdenticalToState: this.evalPathsIdenticalToState,
});
});
}

View File

@ -6,7 +6,7 @@ import type {
WidgetEntity,
WidgetEntityConfig,
} from "entities/DataTree/dataTreeFactory";
import { get, isUndefined, set } from "lodash";
import { get, isObject, isUndefined, set } from "lodash";
import type { EvaluationError } from "utils/DynamicBindingUtils";
import {
getEvalValuePath,
@ -21,10 +21,40 @@ import {
} from "@appsmith/workers/Evaluation/evaluationUtils";
import { validate } from "workers/Evaluation/validations";
import type { EvalProps } from ".";
import type { ValidationResponse } from "constants/WidgetValidation";
const LARGE_COLLECTION_SIZE = 100;
const getIsLargeCollection = (val: any) => {
if (!Array.isArray(val)) return false;
const rowSize = !isObject(val[0]) ? 1 : Object.keys(val[0]).length;
const size = val.length * rowSize;
return size > LARGE_COLLECTION_SIZE;
};
export function setToEvalPathsIdenticalToState({
evalPath,
evalPathsIdenticalToState,
evalProps,
isParsedValueTheSame,
statePath,
value,
}: any) {
const isLargeCollection = getIsLargeCollection(value);
if (isParsedValueTheSame && isLargeCollection) {
evalPathsIdenticalToState[evalPath] = statePath;
} else {
delete evalPathsIdenticalToState[evalPath];
set(evalProps, evalPath, value);
}
}
export function validateAndParseWidgetProperty({
configTree,
currentTree,
evalPathsIdenticalToState,
evalPropertyValue,
evalProps,
fullPropertyPath,
@ -38,6 +68,7 @@ export function validateAndParseWidgetProperty({
evalPropertyValue: unknown;
unEvalPropertyValue: string;
evalProps: EvalProps;
evalPathsIdenticalToState: any;
}): unknown {
const { propertyPath } = getEntityNameAndPropertyPath(fullPropertyPath);
if (isPathDynamicTrigger(widget, propertyPath)) {
@ -85,14 +116,21 @@ export function validateAndParseWidgetProperty({
configTree,
});
}
set(
const evalPath = getEvalValuePath(fullPropertyPath, {
isPopulated: false,
fullPath: true,
});
const isParsedValueTheSame = parsed === evaluatedValue;
setToEvalPathsIdenticalToState({
evalPath,
evalPathsIdenticalToState,
evalProps,
getEvalValuePath(fullPropertyPath, {
isPopulated: false,
fullPath: true,
}),
evaluatedValue,
);
isParsedValueTheSame,
statePath: fullPropertyPath,
value: evaluatedValue,
});
return parsed;
}
@ -115,7 +153,7 @@ export function validateWidgetProperty(
export function validateActionProperty(
config: ValidationConfig,
value: unknown,
) {
): ValidationResponse {
if (!config) {
return {
isValid: true,
@ -127,10 +165,10 @@ export function validateActionProperty(
export function getValidatedTree(
tree: DataTree,
option: { evalProps: EvalProps },
option: { evalProps: EvalProps; evalPathsIdenticalToState: any },
configTree: ConfigTree,
) {
const { evalProps } = option;
const { evalPathsIdenticalToState, evalProps } = option;
return Object.keys(tree).reduce((tree, entityKey: string) => {
const entity = tree[entityKey];
if (!isWidget(entity)) {
@ -152,15 +190,21 @@ export function getValidatedTree(
? value
: transformed;
const isParsedValueTheSame = parsed === evaluatedValue;
const fullPropertyPath = `${entityKey}.${property}`;
set(
const evalPath = getEvalValuePath(fullPropertyPath, {
isPopulated: false,
fullPath: true,
});
setToEvalPathsIdenticalToState({
evalPath,
evalPathsIdenticalToState,
evalProps,
getEvalValuePath(fullPropertyPath, {
isPopulated: false,
fullPath: true,
}),
evaluatedValue,
);
isParsedValueTheSame,
statePath: fullPropertyPath,
value: evaluatedValue,
});
resetValidationErrorsForEntityProperty({
evalProps,

View File

@ -5,7 +5,6 @@ import forge from "node-forge";
import { defaultLibraries } from "./index";
import { JSLibraries, libraryReservedIdentifiers } from "./index";
import { invalidEntityIdentifiers } from "../DependencyMap/utils";
const defaultLibImplementations = {
lodash: _,
moment: moment,