Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/kitchen-sink/src/ensemble/screens/forms.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -588,3 +588,6 @@ Global: |
}]
}
}
const sayHello = () => {
window.alert("hello world!");
};
7 changes: 4 additions & 3 deletions apps/kitchen-sink/src/ensemble/screens/home.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ View:
onTap:
executeCode: |
// Calls a function defined in test.js
debugger;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove?

sayHello();

- Button:
Expand Down Expand Up @@ -402,7 +403,7 @@ View:
onResponse:
executeCode: |
ensemble.storage.set('email', response.body.results[0].email)
ensemble.storage.set('emails', [...ensemble.storage.get("emails"),response.body.results[0].email])
ensemble.storage.set('emails', [...(ensemble.storage.get("emails") || []),response.body.results[0].email])
console.log('getData', response.body.results[0].email, ensemble.storage.get('emails'));
- Column:
item-template:
Expand Down Expand Up @@ -463,7 +464,7 @@ View:
data: ${ensemble.storage.get('products')}
onSearch:
executeCode: |
ensemble.invokeAPI('getProducts', { search: search }).then((res) => {
ensemble.invokeAPI('getProducts', { search }).then((res) => {
const users = res?.body?.users || [];
console.log(users , "users");
const newUsers = users.map((i) => ({ ...i, label: i.firstName + ' ' + i.lastName, name: i.firstName + ' ' + i.lastName, value: i.email }));
Expand All @@ -473,7 +474,7 @@ View:
console.log("onSearch values: ", search);
onChange:
executeCode: |
console.log("onChange values: ", search);
console.log("onChange values: ", value);

Global:
scriptName: test.js
Expand Down
36 changes: 36 additions & 0 deletions packages/framework/src/evaluate/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { buildEvaluateFn, testGetScriptCacheSize } from "../evaluate";
import type { ScreenContextDefinition } from "../../state";

const importScript = `function shared(x){return x+1}; const sharedConst=42;`;
const globalScript1 = `const unique=10; function calc(){return shared(unique)+sharedConst}`;
const globalScript2 = `const unique=20; function calc(){return shared(unique)+sharedConst}`;

// construct a minimal ScreenContextDefinition subset that buildEvaluateFn expects
const makeScreen = (global: string): Partial<ScreenContextDefinition> => ({
model: {
id: "test",
name: "test",
body: { name: "Row", properties: {} },
importedScripts: importScript,
global,
},
});

it("caches import script only once across multiple screens", () => {
const before = testGetScriptCacheSize();

const fn1 = buildEvaluateFn(makeScreen(globalScript1), "calc()");
fn1();

const fn2 = buildEvaluateFn(makeScreen(globalScript2), "calc()");
fn2();

const after = testGetScriptCacheSize();

// cache should have grown by exactly 3 entries: 1 import + 2 globals
expect(after - before).toBe(3);

// validating evaluated results
expect(fn1() as number).toBe(53); // 10 + 1 + 42
expect(fn2() as number).toBe(63); // 20 + 1 + 42
});
167 changes: 161 additions & 6 deletions packages/framework/src/evaluate/evaluate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isEmpty, merge, toString } from "lodash-es";
import { parse as acornParse } from "acorn";
import type { ScreenContextDefinition } from "../state/screen";
import type { InvokableMethods, WidgetState } from "../state/widget";
import {
Expand All @@ -9,6 +10,73 @@ import {
replace,
} from "../shared";

/**
* Cache of compiled global / imported scripts keyed by the full script string.
* Each entry stores the symbol names and their corresponding values so that we
* can inject them as parameters when evaluating bindings, removing the need to
* re-parse the same script for every binding.
*/
interface CachedScriptEntry {
symbols: string[];
// compiled function that, given a context, returns an object of exports
fn: (ctx: { [key: string]: unknown }) => { [key: string]: unknown };
}

const globalScriptCache = new Map<string, CachedScriptEntry>();

/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument */
const parseScriptSymbols = (script: string): string[] => {
const symbols = new Set<string>();
try {
const ast: any = acornParse(script, {
ecmaVersion: 2020,
sourceType: "script",
});

ast.body?.forEach((node: any) => {
if (node.type === "FunctionDeclaration" && node.id) {
symbols.add(node.id.name);
}
if (node.type === "VariableDeclaration") {
node.declarations.forEach((decl: any) => {
if (decl.id?.type === "Identifier") {
symbols.add(decl.id.name);
}
});
}
});
} catch (e) {
debug(e);
}
return Array.from(symbols);
};
/* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument */

const getCachedGlobals = (
script: string,
ctx: { [key: string]: unknown },
): { symbols: string[]; values: unknown[] } => {
if (isEmpty(script.trim())) return { symbols: [], values: [] };

let entry = globalScriptCache.get(script);
const symbols = parseScriptSymbols(script);

// build a function that executes the script within the provided context using `with`
// and returns an object containing the exported symbols
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
const compiled = new Function(
"ctx",
`with (ctx) {\n${script}\nreturn { ${symbols.join(", ")} };\n}`,
) as CachedScriptEntry["fn"];

entry = { symbols, fn: compiled };
globalScriptCache.set(script, entry);

const exportsObj = entry.fn(ctx);
const values = entry.symbols.map((name) => exportsObj[name]);
return { symbols: entry.symbols, values };
};

export const widgetStatesToInvokables = (widgets: {
[key: string]: WidgetState | undefined;
}): [string, InvokableMethods | undefined][] => {
Expand Down Expand Up @@ -38,17 +106,49 @@ export const buildEvaluateFn = (
// Need to filter out invalid JS identifiers
].filter(([key, _]) => !key.includes(".")),
);
const globalBlock = screen.model?.global;
const importedScriptBlock = screen.model?.importedScripts;
const globalBlock = screen.model?.global ?? "";
const importedScriptBlock = screen.model?.importedScripts ?? "";

// 1️⃣ cache/compile the IMPORT block (shared across screens)
const importResult = getCachedGlobals(
importedScriptBlock,
merge({}, context, invokableObj),
);

// build an object of import exports so the global block can access them
const importExportsObj = Object.fromEntries(
importResult.symbols.map((s, i) => [s, importResult.values[i]]),
);

// 2️⃣ cache/compile the GLOBAL block (per screen) with import exports in scope
const globalResult = getCachedGlobals(
globalBlock,
merge({}, context, invokableObj, importExportsObj),
);

// 3️⃣ merge symbols and values (global overrides import if duplicate)
const symbolValueMap = new Map<string, unknown>();
importResult.symbols.forEach((sym, idx) => {
symbolValueMap.set(sym, importResult.values[idx]);
});
globalResult.symbols.forEach((sym, idx) => {
symbolValueMap.set(sym, globalResult.values[idx]);
});

const allSymbols = Array.from(symbolValueMap.keys());
const allValues = Array.from(symbolValueMap.values());

// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
const jsFunc = new Function(
...Object.keys(invokableObj),
addScriptBlock(formatJs(js), globalBlock, importedScriptBlock),
// addScriptBlock(formatJs(js), globalBlock, importedScriptBlock),

...allSymbols,
formatJs(js),
);

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return () => jsFunc(...Object.values(invokableObj));
// return () => jsFunc(...Object.values(invokableObj)) as unknown;
return () => jsFunc(...Object.values(invokableObj), ...allValues) as unknown;
};

const formatJs = (js?: string): string => {
Expand Down Expand Up @@ -114,6 +214,55 @@ const addScriptBlock = (
return (jsString += `${js}`);
};

// map to store binding evaluation statistics keyed by sanitized expression label
interface BindingStats {
count: number;
total: number;
max: number;
min: number;
}

// in-memory cache for quick inspection in dev builds (not used in production)
const bindingEvaluationStats = new Map<string, BindingStats>();

const timestamp = (): number => {
// use high-resolution timer when available
if (
typeof performance !== "undefined" &&
typeof performance.now === "function"
) {
return performance.now();
}
// Date.now fallback – millisecond precision
return Date.now();
};

const recordBindingEvaluation = (
expr: string | undefined,
duration: number,
): void => {
if (!expr) return;

// keep label concise for easy reading; remove surrounding `${}` if present
const label = sanitizeJs(toString(expr)).slice(0, 100);
const existing = bindingEvaluationStats.get(label) ?? {
count: 0,
total: 0,
max: 0,
min: Number.POSITIVE_INFINITY,
};

existing.count += 1;
existing.total += duration;
existing.max = Math.max(existing.max, duration);
existing.min = Math.min(existing.min, duration);

bindingEvaluationStats.set(label, existing);
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(globalThis as any).__bindingEvaluationStats = bindingEvaluationStats;

/**
* @deprecated Consider using useEvaluate or createBinding which will
* optimize creating the evaluation context
Expand All @@ -129,7 +278,11 @@ export const evaluate = <T = unknown>(
context?: { [key: string]: unknown },
): T => {
try {
return buildEvaluateFn(screen, js, context)() as T;
const start = timestamp();
const result = buildEvaluateFn(screen, js, context)() as T;
const duration = timestamp() - start;
recordBindingEvaluation(js, duration);
return result;
} catch (e) {
debug(e);
throw e;
Expand All @@ -147,3 +300,5 @@ export const evaluateDeep = (
);
return resolvedInputs as { [key: string]: unknown };
};

export const testGetScriptCacheSize = (): number => globalScriptCache.size;
Loading