Skip to content

Commit f6118e4

Browse files
authored
Merge pull request #110 from bcdev/yogesh-xcube-viewer-490-fix-unnecessary-state-changes
Add memoization for invoking callbacks
2 parents e18dc5c + 7a49490 commit f6118e4

File tree

9 files changed

+250
-18
lines changed

9 files changed

+250
-18
lines changed

chartlets.js/CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Add `multiple` property for `Select` component to enable the selection
44
of multiple elements. The `default` mode is supported at the moment.
55

6+
* Callbacks will now only be invoked when there’s an actual change in state,
7+
reducing unnecessary processing and improving performance. (#112)
68

79
## Version 0.1.4 (from 2025/03/06)
810

chartlets.js/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

chartlets.js/packages/lib/src/actions/handleHostStoreChange.ts

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { invokeCallbacks } from "@/actions/helpers/invokeCallbacks";
1111
import type { ContributionState } from "@/types/state/contribution";
1212
import type { HostStore } from "@/types/state/host";
1313
import { store } from "@/store";
14+
import { shallowEqualArrays } from "@/utils/compare";
1415

1516
/**
1617
* A reference to a property of an input of a callback of a contribution.
@@ -43,29 +44,65 @@ export function handleHostStoreChange() {
4344
contributionsRecord,
4445
hostStore,
4546
);
46-
if (callbackRequests && callbackRequests.length > 0) {
47-
invokeCallbacks(callbackRequests);
47+
48+
const filteredCallbackRequests = callbackRequests.filter(
49+
(callbackRequest): callbackRequest is CallbackRequest =>
50+
callbackRequest !== undefined,
51+
);
52+
if (filteredCallbackRequests && filteredCallbackRequests.length > 0) {
53+
invokeCallbacks(filteredCallbackRequests);
4854
}
4955
}
5056

51-
function getCallbackRequests(
57+
// Exporting for testing only
58+
export function getCallbackRequests(
5259
propertyRefs: PropertyRef[],
5360
contributionsRecord: Record<string, ContributionState[]>,
5461
hostStore: HostStore,
55-
): CallbackRequest[] {
56-
return propertyRefs.map((propertyRef) => {
57-
const contributions = contributionsRecord[propertyRef.contribPoint];
58-
const contribution = contributions[propertyRef.contribIndex];
59-
const callback = contribution.callbacks![propertyRef.callbackIndex];
60-
const inputValues = getInputValues(
61-
callback.inputs!,
62-
contribution,
62+
): (CallbackRequest | undefined)[] {
63+
const { lastCallbackInputValues } = store.getState();
64+
return propertyRefs.map((propertyRef) =>
65+
getCallbackRequest(
66+
propertyRef,
67+
lastCallbackInputValues,
68+
contributionsRecord,
6369
hostStore,
64-
);
65-
return { ...propertyRef, inputValues };
66-
});
70+
),
71+
);
6772
}
6873

74+
const getCallbackRequest = (
75+
propertyRef: PropertyRef,
76+
lastCallbackInputValues: Record<string, unknown[]>,
77+
contributionsRecord: Record<string, ContributionState[]>,
78+
hostStore: HostStore,
79+
) => {
80+
const contribPoint: string = propertyRef.contribPoint;
81+
const contribIndex: number = propertyRef.contribIndex;
82+
const callbackIndex: number = propertyRef.callbackIndex;
83+
const contributions = contributionsRecord[contribPoint];
84+
const contribution = contributions[contribIndex];
85+
const callback = contribution.callbacks![callbackIndex];
86+
const inputValues = getInputValues(callback.inputs!, contribution, hostStore);
87+
const callbackId = `${contribPoint}-${contribIndex}-${callbackIndex}`;
88+
const lastInputValues = lastCallbackInputValues[callbackId];
89+
if (shallowEqualArrays(lastInputValues, inputValues)) {
90+
// We no longer log, as the situation is quite common
91+
// Enable error logging for debugging only:
92+
// console.groupCollapsed("Skipping callback request");
93+
// console.debug("inputValues", inputValues);
94+
// console.groupEnd();
95+
return undefined;
96+
}
97+
store.setState({
98+
lastCallbackInputValues: {
99+
...lastCallbackInputValues,
100+
[callbackId]: inputValues,
101+
},
102+
});
103+
return { ...propertyRef, inputValues };
104+
};
105+
69106
// TODO: use a memoized selector to get hostStorePropertyRefs
70107
// Note that this will only be effective and once we split the
71108
// static contribution infos and dynamic contribution states.

chartlets.js/packages/lib/src/actions/handleHostStoreChanges.test.tsx

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import { describe, it, expect, beforeEach } from "vitest";
1+
import { beforeEach, describe, expect, it } from "vitest";
22
import { store } from "@/store";
3-
import { handleHostStoreChange } from "./handleHostStoreChange";
3+
import {
4+
getCallbackRequests,
5+
handleHostStoreChange,
6+
type PropertyRef,
7+
} from "./handleHostStoreChange";
8+
import type { ContribPoint } from "@/types/model/extension";
9+
import type { ContributionState } from "@/types/state/contribution";
410

511
describe("handleHostStoreChange", () => {
612
let listeners: (() => void)[] = [];
@@ -15,10 +21,12 @@ describe("handleHostStoreChange", () => {
1521
listeners.push(_l);
1622
},
1723
};
24+
let lastCallbackInputValues: Record<string, unknown[]> = {};
1825

1926
beforeEach(() => {
2027
listeners = [];
2128
hostState = {};
29+
lastCallbackInputValues = {};
2230
});
2331

2432
it("should do nothing without host store", () => {
@@ -78,8 +86,137 @@ describe("handleHostStoreChange", () => {
7886
},
7987
},
8088
},
89+
contributionsRecord: {
90+
panel: [
91+
{
92+
name: "p0",
93+
container: { title: "Panel A" },
94+
extension: "e0",
95+
componentResult: {},
96+
initialState: {},
97+
callbacks: [
98+
{
99+
function: {
100+
name: "callback",
101+
parameters: [],
102+
return: {},
103+
},
104+
inputs: [{ id: "@app", property: "variableName" }],
105+
outputs: [{ id: "select", property: "value" }],
106+
},
107+
],
108+
},
109+
],
110+
},
81111
});
82112
hostStore.set("variableName", "CHL");
83113
handleHostStoreChange();
114+
115+
// calling it second time for coverage. No state change changes the
116+
// control flow
117+
handleHostStoreChange();
118+
// TODO: Update this test to assert the generated callback request
119+
});
120+
121+
it("should memoize second call with same arguments", () => {
122+
const extensions = [{ name: "ext", version: "0", contributes: ["panels"] }];
123+
store.setState({
124+
configuration: { hostStore, logging: { enabled: false } },
125+
extensions,
126+
contributionsResult: {
127+
status: "ok",
128+
data: {
129+
extensions,
130+
contributions: {
131+
panels: [
132+
{
133+
name: "ext.p1",
134+
extension: "ext",
135+
layout: {
136+
function: {
137+
name: "layout",
138+
parameters: [],
139+
return: {},
140+
},
141+
inputs: [],
142+
outputs: [],
143+
},
144+
callbacks: [
145+
{
146+
function: {
147+
name: "callback",
148+
parameters: [],
149+
return: {},
150+
},
151+
inputs: [{ id: "@app", property: "variableName" }],
152+
},
153+
],
154+
initialState: {},
155+
},
156+
],
157+
},
158+
},
159+
},
160+
lastCallbackInputValues: lastCallbackInputValues,
161+
});
162+
const propertyRefs: PropertyRef[] = [
163+
{
164+
contribPoint: "panel",
165+
contribIndex: 0,
166+
callbackIndex: 0,
167+
property: "value",
168+
inputIndex: 0,
169+
},
170+
];
171+
const contributionsRecord: Record<ContribPoint, ContributionState[]> = {
172+
panel: [
173+
{
174+
name: "ext.p1",
175+
container: { title: "Panel A" },
176+
extension: "ext",
177+
componentResult: {},
178+
initialState: { title: "Panel A" },
179+
callbacks: [
180+
{
181+
function: {
182+
name: "callback",
183+
parameters: [{ name: "param1" }],
184+
return: {},
185+
},
186+
inputs: [{ id: "@app", property: "variableName" }],
187+
},
188+
],
189+
},
190+
],
191+
};
192+
193+
hostStore.set("variableName", "CHL");
194+
195+
// first call -> should create callback request
196+
let result = getCallbackRequests(
197+
propertyRefs,
198+
contributionsRecord,
199+
hostStore,
200+
);
201+
expect(result[0]).toEqual({
202+
...propertyRefs[0],
203+
inputValues: ["CHL"],
204+
});
205+
206+
// second call -> memoized -> should not create callback request
207+
result = getCallbackRequests(propertyRefs, contributionsRecord, hostStore);
208+
expect(result).toEqual([undefined]);
209+
210+
// Third call - change state -> should create callback request
211+
hostStore.set("variableName", "TMP");
212+
result = getCallbackRequests(propertyRefs, contributionsRecord, hostStore);
213+
expect(result[0]).toEqual({
214+
...propertyRefs[0],
215+
inputValues: ["TMP"],
216+
});
217+
218+
// fourth call -> memoized -> should not invoke callback
219+
result = getCallbackRequests(propertyRefs, contributionsRecord, hostStore);
220+
expect(result).toEqual([undefined]);
84221
});
85222
});

chartlets.js/packages/lib/src/actions/helpers/getInputValues.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function getInputValues(
2626

2727
const noValue = {};
2828

29-
export function getInputValue(
29+
function getInputValue(
3030
input: Input,
3131
contributionState: ContributionState,
3232
hostStore?: HostStore,

chartlets.js/packages/lib/src/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export const store = create<StoreState>(() => ({
77
extensions: [],
88
contributionsResult: {},
99
contributionsRecord: {},
10+
lastCallbackInputValues: {},
1011
}));

chartlets.js/packages/lib/src/types/state/store.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,9 @@ export interface StoreState {
2525
* See hook `useThemeMode()`.
2626
*/
2727
themeMode?: ThemeMode;
28+
/**
29+
* Store last input values for callback requests to avoid invoking them if
30+
* there are no state changes
31+
*/
32+
lastCallbackInputValues: Record<string, unknown[]>;
2833
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from "vitest";
2+
import { shallowEqualArrays } from "@/utils/compare";
3+
4+
describe("Test shallowEqualArrays()", () => {
5+
const arr_a: string[] = ["a", "b", "c"];
6+
const arr_b: string[] = ["a", "b", "c"];
7+
const arr_c: string[] = ["a", "b", "d"];
8+
const arr_d: string[] = [];
9+
const arr_e: string[] = ["a", "b", "c", "d"];
10+
const arr_f: (string | null)[] = ["a", "b", "c", null];
11+
const arr_g: (string | null)[] = ["a", "b", "c", null];
12+
const arr_h = [1, [1, 2, 3], [4, 5, 6]];
13+
const arr_i = [1, [1, 2, 3], [4, 5, 6]];
14+
const arr_j: (number | string | null)[] = [1, 2, "c", null];
15+
const arr_k: (number | string | null)[] = [1, 3, "c", null];
16+
const arr_l: (number | string | null)[] = [1, 2, "c", null];
17+
const arr_m: number[] = [1, 2];
18+
const arr_n: number[] = [1, 2];
19+
const arr_o: null[] = [null];
20+
const arr_p: null[] = [null];
21+
const arr_q: null[] = [];
22+
it("works", () => {
23+
expect(shallowEqualArrays(arr_a, arr_b)).toBe(true);
24+
expect(shallowEqualArrays(arr_a, arr_c)).toBe(false);
25+
expect(shallowEqualArrays(arr_a, arr_d)).toBe(false);
26+
expect(shallowEqualArrays(arr_a, arr_e)).toBe(false);
27+
expect(shallowEqualArrays(arr_e, arr_c)).toBe(false);
28+
expect(shallowEqualArrays(arr_f, arr_g)).toBe(true);
29+
expect(shallowEqualArrays(arr_h, arr_i)).toBe(false);
30+
expect(shallowEqualArrays(arr_j, arr_k)).toBe(false);
31+
expect(shallowEqualArrays(arr_j, arr_l)).toBe(true);
32+
expect(shallowEqualArrays(arr_m, arr_n)).toBe(true);
33+
expect(shallowEqualArrays(arr_m, arr_l)).toBe(false);
34+
expect(shallowEqualArrays(arr_o, arr_p)).toBe(true);
35+
expect(shallowEqualArrays(arr_p, arr_q)).toBe(false);
36+
expect(shallowEqualArrays(arr_p)).toBe(false);
37+
});
38+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function shallowEqualArrays(
2+
arr1?: unknown[],
3+
arr2?: unknown[],
4+
): boolean {
5+
if (!arr1 || !arr2) {
6+
return false;
7+
}
8+
if (arr1.length !== arr2.length) {
9+
return false;
10+
}
11+
return arr1.every((val, index) => val === arr2[index]);
12+
}

0 commit comments

Comments
 (0)