Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Execute dynamically evaluated JavaScript (callbacks, `_call_script`, and `_call_function`) within an explicit function environment providing required runtime dependencies (`addEvents`, `ReflexEvent`, `applyEventActions`, and `refs`), avoiding reliance on module lexical scope, and replace dynamic component evaluation with native dynamic imports.
135 changes: 99 additions & 36 deletions packages/reflex-base/src/reflex_base/.templates/web/utils/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ export const evalReactComponent = async (component) => {
}
const encodedJs = encodeURIComponent(component);
const dataUri = "data:text/javascript;charset=utf-8," + encodedJs;
const module = await eval(`import(dataUri)`);
const module = await import(/* @vite-ignore */ dataUri);
return module.default;
};

Expand Down Expand Up @@ -506,14 +506,80 @@ function urlFrom(string) {
return undefined;
}

/**
* Cached fallback evaluator for arbitrary statements, constructed lazily on first use
* without access to module or caller lexical scope.
*/
let fallbackEval = null;

/**
* Evaluate dynamic JavaScript within an explicit function environment.
*
* Tries expression evaluation first to avoid eval entirely; if the code contains
* statement syntax that cannot be evaluated as an expression, falls back to the
* isolated evaluator while preserving completion value semantics and providing
* only explicit dependencies.
*
* @param code The JavaScript code to evaluate.
* @param env Explicit environment containing runtime dependencies.
* @returns The evaluated completion value.
*/
const evaluateInExplicitEnvironment = (code, env) => {
const trimmed = code.trim().replace(/;+$/, "");
let fn;
try {
fn = new Function(
"addEvents",
"ReflexEvent",
"applyEventActions",
"refs",
`"use strict"; return (${trimmed});`,
);
} catch (e) {
if (e instanceof SyntaxError) {
if (!fallbackEval) {
fallbackEval = new Function(
"addEvents",
"ReflexEvent",
"applyEventActions",
"refs",
`"use strict"; return eval(arguments[4]);`,
);
}
return fallbackEval(
env.addEvents,
env.ReflexEvent,
env.applyEventActions,
env.refs,
code,
);
}
throw e;
}
return fn(env.addEvents, env.ReflexEvent, env.applyEventActions, env.refs);
};

/**
* Construct the explicit execution environment for dynamic JavaScript.
*
* @param addEvents Late-bound event loop dispatcher.
* @returns An object containing explicit runtime dependencies.
*/
const createExecutionContext = (addEvents) => ({
addEvents,
ReflexEvent: typeof ReflexEvent !== "undefined" ? ReflexEvent : undefined,
applyEventActions:
typeof applyEventActions !== "undefined" ? applyEventActions : undefined,
refs: typeof refs !== "undefined" ? refs : (globalThis.refs ?? {}),
});

/**
* Invoke an event's result callback, if it declared one.
*
* The callback arrives as a string built by ``format_queue_events``, which
* references ``queueEvents``/``processEvent`` (module-level here) plus ``socket``,
* ``navigate`` and ``params``. Those three MUST stay the parameter names below:
* the ``eval`` resolves them from this function's scope, so renaming them breaks
* every callback.
* The callback arrives as a function or a string built by ``format_queue_events``,
* which references ``addEvents`` and ``ReflexEvent``. It is evaluated within an
* explicit function environment rather than relying on module or caller lexical scope.
*
* @param event The event whose callback to run.
* @param eval_result The value to pass to the callback, awaited if thenable.
* @param socket The socket object to send events on.
Expand All @@ -530,20 +596,21 @@ const applyResultCallback = async (
if (!event.payload.callback) {
return;
}
// Eval'd user code dispatches through addEvents like compiled event triggers
// do; late-bound so a remounted EventLoopProvider is picked up. Declared in
// every scope holding an eval, since that is the only scope chain the eval'd
// code can resolve it from.
// User code dispatches through addEvents like compiled event triggers
// do; late-bound so a remounted EventLoopProvider is picked up.
const addEvents = (...args) => eventLoop.addEvents(...args);
const final_result =
!!eval_result && typeof eval_result.then === "function"
? await eval_result
: eval_result;
const callback =
typeof event.payload.callback === "string"
? eval(event.payload.callback)
: event.payload.callback;
callback(final_result);
let callback = event.payload.callback;
if (typeof callback === "string") {
callback = evaluateInExplicitEnvironment(
callback,
createExecutionContext(addEvents),
);
}
await callback(final_result);
};

/**
Expand Down Expand Up @@ -586,10 +653,8 @@ const storageDefault = (state_key) => {
* @param params The params object from useParams
*/
export const applyEvent = async (event, socket, navigate, params) => {
// Eval'd user code dispatches through addEvents like compiled event triggers
// do; late-bound so a remounted EventLoopProvider is picked up. Declared in
// every scope holding an eval, since that is the only scope chain the eval'd
// code can resolve it from.
// User code dispatches through addEvents like compiled event triggers
// do; late-bound so a remounted EventLoopProvider is picked up.
const addEvents = (...args) => eventLoop.addEvents(...args);
// Handle special events
if (event.name == "_redirect") {
Expand Down Expand Up @@ -734,18 +799,17 @@ export const applyEvent = async (event, socket, navigate, params) => {
return;
}

if (
event.name == "_call_function" &&
typeof event.payload.function !== "string"
) {
if (event.name == "_call_function") {
try {
await applyResultCallback(
event,
event.payload.function(),
socket,
navigate,
params,
);
let func = event.payload.function;
if (typeof func === "string") {
func = evaluateInExplicitEnvironment(
func,
createExecutionContext(addEvents),
);
}
const eval_result = func();
await applyResultCallback(event, eval_result, socket, navigate, params);
} catch (e) {
console.log("_call_function", e);
if (window && window?.onerror) {
Expand All @@ -755,13 +819,12 @@ export const applyEvent = async (event, socket, navigate, params) => {
return;
}

if (event.name == "_call_script" || event.name == "_call_function") {
if (event.name == "_call_script") {
try {
const eval_result =
event.name == "_call_script"
? eval(event.payload.javascript_code)
: eval(event.payload.function)();

const eval_result = evaluateInExplicitEnvironment(
event.payload.javascript_code,
createExecutionContext(addEvents),
);
await applyResultCallback(event, eval_result, socket, navigate, params);
} catch (e) {
console.log("_call_script", e);
Expand Down
4 changes: 2 additions & 2 deletions packages/reflex-base/src/reflex_base/utils/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,8 @@ def format_queue_events(

The resulting code can be passed to interfaces that expect a callback
function and when triggered it dispatches the events through `addEvents`.
The name resolves via the attached imports in compiled modules and via a
local alias in the `applyResultCallback` eval context.
The name resolves via the attached imports in compiled modules and via an
explicit environment in the `applyResultCallback` execution context.

Args:
events: The events to queue.
Expand Down
165 changes: 146 additions & 19 deletions tests/units/compiler/test_state_js_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@

ADD_EVENTS_ALIAS = "const addEvents = (...args) => eventLoop.addEvents(...args);"

USER_CODE_EVALS = (
"eval(event.payload.callback)",
"eval(event.payload.javascript_code)",
"eval(event.payload.function)",
USER_CODE_EXECUTIONS = (
"evaluateInExplicitEnvironment(\n callback,\n createExecutionContext(addEvents),",
"evaluateInExplicitEnvironment(\n func,\n createExecutionContext(addEvents),",
"evaluateInExplicitEnvironment(\n event.payload.javascript_code,\n createExecutionContext(addEvents),",
)

TOP_LEVEL_FUNCTION = re.compile(
Expand Down Expand Up @@ -50,9 +50,7 @@ def test_result_callback_dispatches_through_current_event_loop() -> None:
pytest.skip("Node.js is needed to execute the callback")
content = STATE_JS_TEMPLATE.read_text()
helper = content[
content.index("const applyResultCallback =") : content.index(
"export const applyEvent ="
)
content.index("let fallbackEval =") : content.index("export const applyEvent =")
]
subprocess.run(
[
Expand All @@ -79,6 +77,129 @@ def test_result_callback_dispatches_through_current_event_loop() -> None:
)


def test_eval_react_component_dynamic_import() -> None:
"""Evaluate dynamic component imports via native dynamic import."""
node = shutil.which("node")
if node is None:
pytest.skip("Node.js is needed to execute evalReactComponent")
content = STATE_JS_TEMPLATE.read_text()
helper = content[
content.index("export const evalReactComponent =") : content.index(
"export const queueEventIfSocketExists ="
)
]
subprocess.run(
[
node,
"--input-type=module",
"--eval",
helper
+ """
import assert from 'node:assert/strict';
const loadedLibs = [];
globalThis.window = {
__reflex: { react: { name: 'injected_react' } },
__reflex_load: async (libs) => { loadedLibs.push(...libs); },
};
const componentSource = `//__reflex_evaluate:["lib1", "lib2"]
export default function Widget() { return window.React.name; }
`;
const comp = await evalReactComponent(componentSource);
assert.deepEqual(loadedLibs, ['lib1', 'lib2']);
assert.equal(comp(), 'injected_react');
assert.equal(window.React.name, 'injected_react');
""",
],
check=True,
capture_output=True,
text=True,
)


def test_dynamic_javascript_explicit_environment() -> None:
"""Dynamic code executes within explicit environments preserving completion values."""
node = shutil.which("node")
if node is None:
pytest.skip("Node.js is needed to execute dynamic evaluation")
content = STATE_JS_TEMPLATE.read_text()
helper = content[
content.index("let fallbackEval =") : content.index("export const applyEvent =")
]
subprocess.run(
[
node,
"--input-type=module",
"--eval",
"""
import assert from 'node:assert/strict';
const calls = [];
const eventLoop = {addEvents: (events) => calls.push(events)};
const ReflexEvent = (name, payload) => ({name, payload});
const refs = {target: {current: {value: 'ref_val'}}};
"""
+ helper
+ """
const env = createExecutionContext((...args) => eventLoop.addEvents(...args));

assert.equal(fallbackEval, null);
assert.equal(evaluateInExplicitEnvironment('15 + 27', env), 42);
assert.equal(fallbackEval, null);
assert.equal(evaluateInExplicitEnvironment('let a = 10; let b = 20; a + b;', env), 30);
assert.notEqual(fallbackEval, null);
const cachedEvaluator = fallbackEval;
assert.equal(evaluateInExplicitEnvironment('let c = 5; c * 2;', env), 10);
assert.equal(fallbackEval, cachedEvaluator);

assert.equal(evaluateInExplicitEnvironment('console.log("no-op");', env), undefined);

assert.throws(
() => evaluateInExplicitEnvironment('strictUndeclaredExpr = 42', env),
{ name: 'ReferenceError', message: 'strictUndeclaredExpr is not defined' },
);
assert.equal('strictUndeclaredExpr' in globalThis, false);

assert.throws(
() => evaluateInExplicitEnvironment('let x = 1; strictUndeclaredStmt = 42; x;', env),
{ name: 'ReferenceError', message: 'strictUndeclaredStmt is not defined' },
);
assert.equal('strictUndeclaredStmt' in globalThis, false);

assert.equal(evaluateInExplicitEnvironment('this', env), undefined);
assert.equal(evaluateInExplicitEnvironment('let y = 1; this;', env), undefined);

evaluateInExplicitEnvironment('addEvents([ReflexEvent("event_a", {val: refs.target.current.value})])', env);
assert.deepEqual(calls.pop(), [{name: 'event_a', payload: {val: 'ref_val'}}]);

assert.throws(
() => evaluateInExplicitEnvironment('socket', env),
{ name: 'ReferenceError', message: 'socket is not defined' },
);
assert.throws(
() => evaluateInExplicitEnvironment('navigate', env),
{ name: 'ReferenceError', message: 'navigate is not defined' },
);
assert.throws(
() => evaluateInExplicitEnvironment('params', env),
{ name: 'ReferenceError', message: 'params is not defined' },
);

globalThis.asyncDone = false;
const event = {
payload: {
callback: 'async (res) => { await Promise.resolve(); globalThis.asyncDone = true; addEvents([res * 2]); }',
},
};
await applyResultCallback(event, Promise.resolve(21), null, null, null);
assert.equal(globalThis.asyncDone, true);
assert.deepEqual(calls.pop(), [42]);
""",
],
check=True,
capture_output=True,
text=True,
)


def test_socket_startup_lifecycle() -> None:
"""Execute the frontend startup and reconnect tests against the real template."""
node = shutil.which("node")
Expand Down Expand Up @@ -135,22 +256,28 @@ def test_state_js_still_handles_page_lifecycle_disconnect() -> None:


def test_every_user_code_eval_can_reach_add_events() -> None:
"""Every scope that evals user code must declare the `addEvents` alias.

A direct `eval` resolves names from its own scope chain, so the alias has to
be declared in each function holding one. Moving it, or extracting an `eval`
into a new helper without it, leaves the eval'd code -- serialized callbacks,
`_call_script`, and the handlers `_call_function` returns -- referencing an
undefined `addEvents`.
"""Every scope executing user code must inject the late-bound `addEvents` alias.

Dynamic execution paths -- serialized callbacks, `_call_script`, and
`_call_function` -- run within an explicit execution environment rather than
relying on lexical scope eval. The `addEvents` alias must be declared in each
enclosing function and passed via `createExecutionContext(addEvents)` so the
evaluated code can resolve it through explicit dependency injection. Direct
eval of user code payload strings must not be present.
"""
content = STATE_JS_TEMPLATE.read_text()

for snippet in USER_CODE_EVALS:
eval_at = content.index(snippet)
name, body = _enclosing_function(content, eval_at)
assert "eval(event.payload.callback)" not in content
assert "eval(event.payload.javascript_code)" not in content
assert "eval(event.payload.function)" not in content
assert "eval(`import(dataUri)`)" not in content

for snippet in USER_CODE_EXECUTIONS:
exec_at = content.index(snippet)
name, body = _enclosing_function(content, exec_at)
assert ADD_EVENTS_ALIAS in body, (
f"`{name}` evals user code via `{snippet}` but does not declare the "
"addEvents alias, so the eval'd code cannot resolve it."
f"`{name}` executes user code via `{snippet}` but does not declare the "
"addEvents alias to pass into the explicit environment."
)
assert body.index(ADD_EVENTS_ALIAS) < body.index(snippet), (
f"`{name}` declares the addEvents alias after `{snippet}`."
Expand Down
Loading