The expression evaluator
A model writes every expr, and a saved document replays in other people’s
browsers, so an expression is untrusted input.
@uicast/expr defines the language expressions are written in, a subset of
JavaScript, and runs it. The subset is JavaScript expressions over JSON
data: the standard methods of strings, numbers and arrays and a short list of
globals, without statements, new, mutation, regular expressions or function
values.
How an expression runs
Evaluator parses an expression once with acorn and runs it itself, checking
every property read and every call under a CPU and allocation
budget. No source reaches the JavaScript engine, so a strict
Content-Security-Policy without unsafe-eval works.
import { Evaluator } from "@uicast/expr";
const evaluator = new Evaluator({ functions: tools });
<RendererProvider evaluator={evaluator} /> // the React bindingThe checks happen at run time, because a name can be built while the expression runs:
"abc".substr(1) // ❌ refused — `substr` is not in the language
"abc"["sub" + "str"](1) // ❌ the same call — the name exists only at run timeUnder Evaluator the call itself is checked, so both lines are refused. Three
rules do most of the work:
- Own properties only. Nothing inherited is readable, so
({}).constructorisundefined. No deny-list is needed. - Only data is readable. Objects, arrays, primitives, a short list of
built-ins. A class instance, a DOM node or a function fails on the first
read, so a real
MouseEventin scope cannot leakevt.target.ownerDocument. - Methods are called, never passed around.
rows.mapas a value is an error.
Where expressions appear
Every site, in one product search (scopes.root.products is seeded higher up):
{ "key": "search", "component": "SearchInput",
"seed": [{ "set": "scopes.root.query", "literal": "" }],
"props": { "expr": "({ value: scopes.root.query })" },
"callbacks": { "onChange": [{ "set": "scopes.root.query", "expr": "evt.value" }] } }
{ "key": "empty", "component": "Typography", "hidden": "scopes.root.products.length > 0",
"props": { "literal": { "text": "No products" } } }
{ "key": "rows", "component": "Typography", "as": "row", "keyBy": "id",
"each": "scopes.root.products.filter(p => p.name.includes(scopes.root.query))",
"props": { "expr": "({ text: scopes.row.name })" } }props is a value source; hidden, loading and
each are bare strings. Inside callbacks, an expression also sees evt. A set step
also sees currentValue, the
value at that path now.
Only seed and callbacks await a result, so only they may call host
functions. A host call in props, hidden, loading or each is
rejected into the error slot.
A scope path read in props, hidden, loading or each subscribes the element to it.
The paths are found in the expression text; nothing is declared. See
Reactivity & Dependencies.
The language
The grammar is a closed allow-list of AST node types; anything else fails validation.
| Kind | Allowed |
|---|---|
| Values | numbers, strings, booleans, null, template literals |
| Access | a.b, a[b], a?.b, a?.[b] (optional receiver, not a?.()) |
| Calls | methods, host functions |
| Operators | arithmetic, comparison, ===/==, &&, ||, ??, !, -, +, typeof, ? : |
| Literals | arrays and objects, with spread and computed keys |
| Functions | arrow functions with an expression body, only as a method’s callback; parameters may destructure |
Left out on purpose:
- Statements and declarations. No
const/let/var, loops,try, blocks orfunction. A runaway loop cannot be written, and with nothing named, nothing can recurse. - Assignment,
++,delete, mutating methods. Expressions are pure by construction: only a step’ssetchanges state..toSorted()and.toReversed()replace.sort()and.reverse(). - Regular expressions. Catastrophic backtracking runs outside the budget.
Text search goes through
.includes(),.startsWith(),.split()and.replaceAll(). - Functions as values. An arrow is only a method’s callback, as in
rows.map(r => r.name): never stored, returned or called. A global that takes one argument can replace it:rows.filter(Boolean). await. The runtime awaits a host call, so the call must be the result: the whole expression, a branch of?:, or the right side of??,||,&&.getUser({ id }).nameandids.map(id => getUser({ id }))are refused before any call runs.Intl. The locale methods still take a locale and options:price.toLocaleString(undefined, { style: "currency", currency: "USD" }).new, soDatevalues,SetandMap. Every value is JSON. A date is an ISO string or a timestamp fromDate.parse(text)orDate.now(); format it in a component (seeDateTime). An object does aMap’s job, andObject.keys(Object.groupBy(rows, r => r.tag))lists distinct tags.forEach, iterators. Callbacks have no effects, and functions are not values.in, bitwise operators,x?.(),instanceof, tagged templates,this. None shapes a display value.
A callback body is one expression too, never a block:
// ✅ a ternary inside the callback
scopes.root.rows.map(r => r.status === "paid" ? "green" : "red")
// ❌ rejected — a block body is a statement list, and there are no statements
scopes.root.rows.map(r => { switch (r.status) { case "paid": return "green" } })Expressions are also stateless: no Math.random (a random value comes
from a host function) and nowhere to keep a counter. Date.now() changes on
every re-evaluation, so the prompt tells the model to take it once in a step and
read it back:
// ❌ a new timestamp every render pass
{ "props": { "expr": "({ text: 'Draft ' + Date.now() })" } }
// ✅ produced once, read back from the path
{ "seed": [{ "set": "scopes.root.draftId", "expr": "Date.now()" }],
"props": { "expr": "({ text: 'Draft ' + scopes.root.draftId })" } }getExpressionsPartialPrompt() gives the model these rules and the globals,
filled from the evaluator’s own tables.
Budgets
Every expression stops: evaluation throws when the budget runs out. Only
built-in methods iterate, and Evaluator implements each one, so every
iteration is charged:
- a step counter (1,000,000 by default): a callback invocation costs its body’s node count, a call costs its work (a sort about n·log n steps);
- a wall-clock deadline (100 ms), on a clock the expression cannot read or move;
- allocation caps, so
"x".repeat(1e9)is refused, not attempted; - an AST depth limit, so nested source cannot overflow the compiler;
- a source-length limit (
maxSourceLength, 1000 characters by default), checked before parsing. The prompt states it to the model.
The step counter is the contract. Each operation is priced by its measured time, and a test keeps each within 4× a plain step, so a long evaluation stops at the same step on any device. The clock is a backstop: in Chrome at 6× CPU slowdown (a mid-range phone) the step limit still fires first; at 12×, locale formatting or comparing two long strings can reach the clock first.
When an expression is rejected
A rejection is a classified
EntryError, a
document fault shown in the element’s error slot:
| expression | reason |
|---|---|
if (scopes.root.n) 1 | expression-syntax — a statement, not an expression |
scopes.root.count = 1 | guardrail-violation — assignment is not in the language |
scopes.root.rows["so" + "rt"]() | guardrail-violation — checked at the call, computed or not |
fetchProducts() | unknown-reference — a name nobody handed in |
Performance
The numbers below come from a fast computer, and from Chrome with its CPU slowed 6× (a mid-range phone) and 12× (a slow phone).
A page with a 50-row table (7 cells a row, the
reference catalog) rendered by React, with its
expressions run by Evaluator or compiled with new Function. Time from the
event to the updated DOM:
| event | evaluator | |||
|---|---|---|---|---|
Evaluator | 15 ms | 37 ms | 78 ms | |
new Function | 14 ms | 34 ms | 71 ms | |
Evaluator | 15 ms | 36 ms | 76 ms | |
new Function | 15 ms | 34 ms | 70 ms | |
Evaluator | 1.5 ms | 4.6 ms | 8.8 ms | |
new Function | 1.3 ms | 3.9 ms | 8.4 ms |
Most of each event is React and the DOM, the same under either evaluator. Per expression, on the fast computer:
| expression | Evaluator | new Function | |
|---|---|---|---|
scopes.root.count > 0 | a hidden condition | ~45 ns | ~4 ns |
scopes.root.user.name | any scope read | ~75 ns | ~6 ns |
| template, two interpolations | a text prop | ~160 ns | ~28 ns |
| object literal from scope | every props.expr | ~180 ns | ~9 ns |
filter, map, reduce over 1000 rows | a derived list | 20–39 µs | 0.7–2 µs |
| re-evaluate a whole 1000-row table, 5 expressions per row | the realistic worst case | ~0.65 ms | ~0.09 ms |
Two prompt rules keep the load small. Neither is an Evaluator limit:
maxListItemsongetCommonInstructionsPartialPrompt(default 100): the model caps each list at that many items and pages past it.- The model leaves real computation to host functions. It asks a function for the page, sort and filters it offers, and reads the totals it returns instead of summing rows. Give your functions those inputs and outputs.
Expression time of whole events (every expression one event re-evaluates), in Chrome:
| event | |||
|---|---|---|---|
| 50-row table, 7 cells per row | 0.3 ms | 1.3 ms | 4.5 ms |
| 100-row table, currency and date formatting per row | 0.4 ms | 2.5 ms | 5.1 ms |
| dashboard over 1,000 orders: 3 KPIs, a chart, a 30-day series | 1.6 ms | 11 ms | 21 ms |
| search keystroke over 1,000 orders | 1.1 ms | 7.1 ms | 16 ms |
| the same over 10,000 orders | 9.8 ms | 60 ms | 123 ms |
| kanban board, 300 cards | 0.2 ms | 3.5 ms | 3.5 ms |
join 1,000 orders to 200 customers with find() per order | 5.2 ms | 33 ms | 68 ms |
A frame is 16 ms. Two kinds of work belong in a host function: the whole table in the browser, and a search per row.
What it does not secure
Expressions cannot reach window, fetch or the rest of the page. They see
only the data and functions you give them. Two things are still up to you:
| Risk | ||
|---|---|---|
| Functions | A document can call every function you give it. If you give it deleteProduct, it can delete products. | Give only the functions it needs. Check permissions on the server. |
| URLs | A document can put your data into an image URL. The browser sends the data when it loads the image. | Declare URL props with z.url(). Then urlPolicy blocks other sites. |
The Security model page has more, including the rules for components.