From 3dce50eed9b70550c5401ef323236563e0b31ff3 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Sat, 28 Jun 2025 01:49:50 +0500 Subject: [PATCH 1/5] cache compiled global scripts to cut per-binding overhead --- .../src/ensemble/screens/forms.yaml | 3 + .../src/ensemble/screens/home.yaml | 7 +- packages/framework/src/evaluate/evaluate.ts | 102 ++++++++++++++---- 3 files changed, 86 insertions(+), 26 deletions(-) diff --git a/apps/kitchen-sink/src/ensemble/screens/forms.yaml b/apps/kitchen-sink/src/ensemble/screens/forms.yaml index 6241839d4..7d85ede1c 100644 --- a/apps/kitchen-sink/src/ensemble/screens/forms.yaml +++ b/apps/kitchen-sink/src/ensemble/screens/forms.yaml @@ -588,3 +588,6 @@ Global: | }] } } + const sayHello = () => { + window.alert("hello world!"); + }; diff --git a/apps/kitchen-sink/src/ensemble/screens/home.yaml b/apps/kitchen-sink/src/ensemble/screens/home.yaml index f0d4a9c8a..d1bda23ae 100644 --- a/apps/kitchen-sink/src/ensemble/screens/home.yaml +++ b/apps/kitchen-sink/src/ensemble/screens/home.yaml @@ -56,6 +56,7 @@ View: onTap: executeCode: | // Calls a function defined in test.js + debugger; sayHello(); - Button: @@ -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: @@ -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 })); @@ -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 diff --git a/packages/framework/src/evaluate/evaluate.ts b/packages/framework/src/evaluate/evaluate.ts index 26e65941c..a400a1d61 100644 --- a/packages/framework/src/evaluate/evaluate.ts +++ b/packages/framework/src/evaluate/evaluate.ts @@ -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 { @@ -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(); + +const parseScriptSymbols = (script: string): string[] => { + const symbols = new Set(); + 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); +}; + +const getCachedGlobals = ( + script: string, + ctx: { [key: string]: unknown }, +): { symbols: string[]; values: unknown[] } => { + if (isEmpty(script.trim())) return { symbols: [], values: [] }; + + let entry = globalScriptCache.get(script); + if (!entry) { + 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][] => { @@ -38,17 +106,23 @@ 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 ?? ""; + const combinedScript = `${importedScriptBlock}\n${globalBlock}`; + + const { symbols: globalSymbols, values: globalValues } = getCachedGlobals( + combinedScript, + merge({}, context, invokableObj), + ); // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func const jsFunc = new Function( ...Object.keys(invokableObj), - addScriptBlock(formatJs(js), globalBlock, importedScriptBlock), + ...globalSymbols, + formatJs(js), ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return () => jsFunc(...Object.values(invokableObj)); + return () => jsFunc(...Object.values(invokableObj), ...globalValues); }; const formatJs = (js?: string): string => { @@ -96,24 +170,6 @@ const formatJs = (js?: string): string => { return `return ${sanitizedJs}`; }; -const addScriptBlock = ( - js: string, - globalBlock?: string, - importedScriptBlock?: string, -): string => { - let jsString = ``; - - if (importedScriptBlock) { - jsString += `${importedScriptBlock}\n\n`; - } - - if (globalBlock) { - jsString += `${globalBlock}\n\n`; - } - - return (jsString += `${js}`); -}; - /** * @deprecated Consider using useEvaluate or createBinding which will * optimize creating the evaluation context From 4548b7a7020c3bca4f97dd7d20ee7dbcf912b6e8 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Sat, 28 Jun 2025 04:48:22 +0500 Subject: [PATCH 2/5] separated caching for import and global scripts --- .../src/evaluate/__tests__/cache.test.ts | 36 +++++++++++++++++++ packages/framework/src/evaluate/evaluate.ts | 35 +++++++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 packages/framework/src/evaluate/__tests__/cache.test.ts diff --git a/packages/framework/src/evaluate/__tests__/cache.test.ts b/packages/framework/src/evaluate/__tests__/cache.test.ts new file mode 100644 index 000000000..32df8aca6 --- /dev/null +++ b/packages/framework/src/evaluate/__tests__/cache.test.ts @@ -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 => ({ + 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 +}); diff --git a/packages/framework/src/evaluate/evaluate.ts b/packages/framework/src/evaluate/evaluate.ts index a400a1d61..cb729d9f2 100644 --- a/packages/framework/src/evaluate/evaluate.ts +++ b/packages/framework/src/evaluate/evaluate.ts @@ -108,21 +108,44 @@ export const buildEvaluateFn = ( ); const globalBlock = screen.model?.global ?? ""; const importedScriptBlock = screen.model?.importedScripts ?? ""; - const combinedScript = `${importedScriptBlock}\n${globalBlock}`; - const { symbols: globalSymbols, values: globalValues } = getCachedGlobals( - combinedScript, + // 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(); + 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), - ...globalSymbols, + ...allSymbols, formatJs(js), ); - return () => jsFunc(...Object.values(invokableObj), ...globalValues); + return () => jsFunc(...Object.values(invokableObj), ...allValues) as unknown; }; const formatJs = (js?: string): string => { @@ -203,3 +226,5 @@ export const evaluateDeep = ( ); return resolvedInputs as { [key: string]: unknown }; }; + +export const testGetScriptCacheSize = (): number => globalScriptCache.size; From 956037fa790fbe35d2486fb6d339a5bd35add37e Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Sat, 28 Jun 2025 04:48:46 +0500 Subject: [PATCH 3/5] gather stats --- packages/framework/src/evaluate/evaluate.ts | 49 ++++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/framework/src/evaluate/evaluate.ts b/packages/framework/src/evaluate/evaluate.ts index cb729d9f2..9ef18f666 100644 --- a/packages/framework/src/evaluate/evaluate.ts +++ b/packages/framework/src/evaluate/evaluate.ts @@ -24,6 +24,7 @@ interface CachedScriptEntry { const globalScriptCache = new Map(); +/* 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(); try { @@ -49,6 +50,7 @@ const parseScriptSymbols = (script: string): string[] => { } 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, @@ -57,7 +59,13 @@ const getCachedGlobals = ( if (isEmpty(script.trim())) return { symbols: [], values: [] }; let entry = globalScriptCache.get(script); - if (!entry) { + const stats = getDevStats(); + + if (entry) { + if (stats) stats.cacheHits += 1; + } else { + if (stats) stats.cacheMisses += 1; + const symbols = parseScriptSymbols(script); // build a function that executes the script within the provided context using `with` @@ -145,7 +153,11 @@ export const buildEvaluateFn = ( formatJs(js), ); - return () => jsFunc(...Object.values(invokableObj), ...allValues) as unknown; + return () => { + const stats = getDevStats(); + if (stats) stats.bindingsEvaluated += 1; + return jsFunc(...Object.values(invokableObj), ...allValues) as unknown; + }; }; const formatJs = (js?: string): string => { @@ -228,3 +240,36 @@ export const evaluateDeep = ( }; export const testGetScriptCacheSize = (): number => globalScriptCache.size; + +// ----------------------------------------------------------------------------- +// Development-time instrumentation (optional) +// ----------------------------------------------------------------------------- + +interface EnsembleStats { + cacheHits: number; + cacheMisses: number; + bindingsEvaluated: number; +} + +/** + * Access a singleton stats object that lives on globalThis so tests and the + * dev console can inspect cache behaviour. + * Only initialised when not in production mode to avoid polluting the global + * scope in production bundles. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const getDevStats = () => { + if (process.env.NODE_ENV === "production") return undefined; + + const g = globalThis as typeof globalThis & { + __ensembleStats?: EnsembleStats; + }; + if (!g.__ensembleStats) { + g.__ensembleStats = { + cacheHits: 0, + cacheMisses: 0, + bindingsEvaluated: 0, + }; + } + return g.__ensembleStats; +}; From 0f871ab3e354f58de5aef7698227effae2999fc4 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Wed, 2 Jul 2025 17:32:12 +0500 Subject: [PATCH 4/5] test before improvements --- packages/framework/src/evaluate/evaluate.ts | 203 +++++++++++--------- 1 file changed, 116 insertions(+), 87 deletions(-) diff --git a/packages/framework/src/evaluate/evaluate.ts b/packages/framework/src/evaluate/evaluate.ts index 9ef18f666..0e644e28c 100644 --- a/packages/framework/src/evaluate/evaluate.ts +++ b/packages/framework/src/evaluate/evaluate.ts @@ -59,26 +59,18 @@ const getCachedGlobals = ( if (isEmpty(script.trim())) return { symbols: [], values: [] }; let entry = globalScriptCache.get(script); - const stats = getDevStats(); + const symbols = parseScriptSymbols(script); - if (entry) { - if (stats) stats.cacheHits += 1; - } else { - if (stats) stats.cacheMisses += 1; - - 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"]; + // 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); - } + entry = { symbols, fn: compiled }; + globalScriptCache.set(script, entry); const exportsObj = entry.fn(ctx); const values = entry.symbols.map((name) => exportsObj[name]); @@ -117,47 +109,46 @@ export const buildEvaluateFn = ( 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(); - 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()); + // // 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(); + // 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), - ...allSymbols, - formatJs(js), + addScriptBlock(formatJs(js), globalBlock, importedScriptBlock), + + // ...allSymbols, + // formatJs(js), ); - return () => { - const stats = getDevStats(); - if (stats) stats.bindingsEvaluated += 1; - return jsFunc(...Object.values(invokableObj), ...allValues) as unknown; - }; + return () => jsFunc(...Object.values(invokableObj)) as unknown; + // return () => jsFunc(...Object.values(invokableObj), ...allValues) as unknown; }; const formatJs = (js?: string): string => { @@ -205,6 +196,73 @@ const formatJs = (js?: string): string => { return `return ${sanitizedJs}`; }; +const addScriptBlock = ( + js: string, + globalBlock?: string, + importedScriptBlock?: string, +): string => { + let jsString = ``; + + if (importedScriptBlock) { + jsString += `${importedScriptBlock}\n\n`; + } + + if (globalBlock) { + jsString += `${globalBlock}\n\n`; + } + + 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(); + +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 @@ -220,7 +278,11 @@ export const evaluate = ( 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; @@ -240,36 +302,3 @@ export const evaluateDeep = ( }; export const testGetScriptCacheSize = (): number => globalScriptCache.size; - -// ----------------------------------------------------------------------------- -// Development-time instrumentation (optional) -// ----------------------------------------------------------------------------- - -interface EnsembleStats { - cacheHits: number; - cacheMisses: number; - bindingsEvaluated: number; -} - -/** - * Access a singleton stats object that lives on globalThis so tests and the - * dev console can inspect cache behaviour. - * Only initialised when not in production mode to avoid polluting the global - * scope in production bundles. - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const getDevStats = () => { - if (process.env.NODE_ENV === "production") return undefined; - - const g = globalThis as typeof globalThis & { - __ensembleStats?: EnsembleStats; - }; - if (!g.__ensembleStats) { - g.__ensembleStats = { - cacheHits: 0, - cacheMisses: 0, - bindingsEvaluated: 0, - }; - } - return g.__ensembleStats; -}; From f6319b5e2f718f3c3d10551592e179dcb82bcb70 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Wed, 2 Jul 2025 17:32:47 +0500 Subject: [PATCH 5/5] test after improvements --- packages/framework/src/evaluate/evaluate.ts | 66 ++++++++++----------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/framework/src/evaluate/evaluate.ts b/packages/framework/src/evaluate/evaluate.ts index 0e644e28c..88df26863 100644 --- a/packages/framework/src/evaluate/evaluate.ts +++ b/packages/framework/src/evaluate/evaluate.ts @@ -109,46 +109,46 @@ export const buildEvaluateFn = ( 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(); - // 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()); + // 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(); + 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), + ...allSymbols, + formatJs(js), ); - return () => jsFunc(...Object.values(invokableObj)) as unknown; - // return () => jsFunc(...Object.values(invokableObj), ...allValues) as unknown; + // return () => jsFunc(...Object.values(invokableObj)) as unknown; + return () => jsFunc(...Object.values(invokableObj), ...allValues) as unknown; }; const formatJs = (js?: string): string => {