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
11 changes: 9 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,10 @@ Existing JSON values are returned as `Cow::Borrowed` so handler and expression h
- Special length property support for arrays and strings (e.g., users.length)
- Numeric array indexes are not resolved by dotted path lookup; loops bind array items by moniker instead
- Nullable path handling via `Option`
- Missing paths return `None`; handler text and attribute bindings render empty, and missing condition values evaluate as false
- Missing paths return `None`; handler text and attribute bindings render empty.
A missing identifier in a condition is a falsy operand, so `path` evaluates
false and `!path` evaluates true. A missing comparison operand still makes
the complete handler condition false.

## Expression Evaluation (webui-expressions)
### Core Function
Expand All @@ -646,6 +649,8 @@ pub fn evaluate(condition: &ConditionExpr, state: &Value) -> Result<bool, Expres
- **Logical operators:** Support for && (AND) and || (OR) only
- **Comparison operators:** Support for >, <, ==, !=, >=, <= only
- **Negation:** Support for ! operator
- **Missing identifiers:** Treat a missing identifier as a falsy operand before
applying negation or logical operators
- **No mixed operators:** Cannot mix AND and OR in the same expression level
- **Operator limit:** Maximum of 5 logical operators per expression
- **Error handling:** Clear, actionable error messages for invalid expressions
Expand Down Expand Up @@ -1644,7 +1649,9 @@ All arrays are optional and omitted from the output when empty to minimize paylo

The closure itself has the shape `(resolve, scope) => boolean`; generated source calls
`resolve(path, scope)` for identifier lookups and preserves the existing WebUI condition
semantics for truthiness, comparison, negation, and `&&` / `||` compounds.
semantics for truthiness, comparison, negation, and `&&` / `||` compounds. A resolver
miss is a falsy identifier operand on both server and client, so `path` is false
and `!path` is true even when the path is absent from a loop item.

> **Known divergence.** A bare identifier compiles to `!!resolve(path, scope)`, i.e.
> host JavaScript truthiness, while the server evaluator in `webui-expressions`
Expand Down
20 changes: 13 additions & 7 deletions crates/webui-expressions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ pub enum ExpressionError {

pub type Result<T> = std::result::Result<T, ExpressionError>;

/// Evaluate a condition expression with the given state
/// Evaluate a condition expression with the given state.
///
/// Missing identifier paths are falsy operands. Missing values used by
/// comparison predicates remain evaluation errors.
pub fn evaluate(condition: &ConditionExpr, state: &Value) -> Result<bool> {
evaluate_with_resolver(condition, |path| find_value_by_dotted_path_ref(path, state))
}
Expand Down Expand Up @@ -136,7 +139,7 @@ where
Value::Object(o) => Ok(!o.is_empty()),
}
} else {
Err(ExpressionError::MissingValue(id.value.clone()))
Ok(false)
}
}
None => Err(ExpressionError::Evaluation(
Expand Down Expand Up @@ -638,15 +641,18 @@ mod tests {
// === Identifier Edge Cases ===

#[test]
fn test_missing_field() {
fn test_missing_identifier_is_falsy_before_negation() {
let condition = ConditionExpr::identifier("notExist");
let negated = ConditionExpr::negated(condition.clone());
let state = test_json!({ "flag": true });

let result = evaluate(&condition, &state);
assert!(
matches!(result, Err(ExpressionError::MissingValue(_))),
"Expected Err(MissingValue), got {:?}",
result
matches!(evaluate(&condition, &state), Ok(false)),
"a missing identifier must be a falsy operand"
);
assert!(
matches!(evaluate(&negated, &state), Ok(true)),
"negating a missing identifier must evaluate to true"
);
}

Expand Down
33 changes: 32 additions & 1 deletion crates/webui-handler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1405,7 +1405,8 @@ impl WebUIHandler {
///
/// Uses a resolver closure that checks local variables first, then falls
/// back to global state — avoiding a full clone of the state tree.
/// Returns false if the condition references a missing value.
/// Missing identifier operands are falsy before logical operators are applied.
/// Missing predicate values make the complete condition false.
fn evaluate_condition(
&self,
condition: &webui_protocol::ConditionExpr,
Expand Down Expand Up @@ -2347,6 +2348,36 @@ mod tests {
assert!(writer_false.is_ended());
}

#[test]
fn missing_identifier_is_falsy_before_negation_in_global_and_loop_scopes() {
let source = r#"<if condition="missingTopLevel">top-positive</if><if condition="!missingTopLevel">top-negated</if><for each="item in items"><if condition="item.searchPresentation"><mark>{{item.label}}</mark></if><if condition="!item.searchPresentation"><span>{{item.label}}</span></if></for>"#;
let mut parser = HtmlParser::new();
parser
.parse("index.html", source)
.expect("parse missing-path condition fixture");
let protocol = WebUIProtocol::new(parser.into_fragment_records());
let state = test_json!({
"items": [
{"label": "Normal"},
{"label": "Search", "searchPresentation": true}
]
});
let mut writer = TestWriter::new();

handle(
&protocol,
&state,
&RenderOptions::new("index.html", "/"),
&mut writer,
)
.expect("render missing-path condition fixture");

assert_eq!(
writer.get_content(),
"top-negated<span>Normal</span><mark>Search</mark>"
);
}

#[test]
fn test_handle_component() {
// Create a protocol with a component
Expand Down
8 changes: 5 additions & 3 deletions docs/ai/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,10 @@ compile to a binary Protocol Buffer at build time. At runtime any backend
(Rust, Node, Go, C#, Python) supplies JSON state and produces HTML. On the
client, interactive components hydrate as islands.

1. **Every template binding must exist in the server state JSON.** If the
1. **Every template binding should exist in the server state JSON.** If the
template uses `{{title}}`, the server must provide `{ "title": "..." }`.
Missing paths render empty and `<if>` evaluates false. No error is raised.
Missing text and attribute paths render empty. A missing condition identifier
is falsy, so `path` is false and `!path` is true. No error is raised.
2. **Derived state belongs in the template or the server.** Use expressions like
`items.length` or `status == 'active'`. Compute complex values server-side.
3. **The server is the source of truth for the initial render.** The client
Expand Down Expand Up @@ -905,7 +906,8 @@ Full detail: [Routing](/guide/concepts/routing).

**Path resolution:** `title`, `user.name`, `items.0.label`, `items.length`

**Missing paths:** text bindings render empty, `<if>` evaluates false. No error.
**Missing paths:** text bindings render empty. In conditions, a missing
identifier is falsy, so `path` is false and `!path` is true. No error.

**Route-scoped state.** Each route handler should return only the keys that
route's template binds to. Sending full app state on every route wastes
Expand Down
5 changes: 4 additions & 1 deletion docs/guide/concepts/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ This page covers proven patterns and common pitfalls when building WebUI applica

## SSR State Completeness

Every binding in your template must have a corresponding key in the server state JSON. The handler resolves bindings by looking up keys - if a key is missing, the binding renders empty or the condition evaluates to false.
Every binding in your template should have a corresponding key in the server
state JSON. The handler resolves bindings by looking up keys. A missing text or
attribute binding renders empty; a missing condition identifier is a falsy
operand, so a positive branch is hidden and a negated branch is shown.

**The rule:** check every `{{binding}}`, `<if condition>`, and `<for each>` in your template and ensure the server provides the data.

Expand Down
7 changes: 6 additions & 1 deletion docs/guide/concepts/directives/if.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ collections are the exception - see the note below the table:
| `"false"` (string) | ⚠️ **Yes** | Non-empty string is truthy! |
| `[]` (empty array) | ⚠️ Differs | Use `items.length` instead |
| `{}` (empty object) | ⚠️ Differs | Test a real field instead |
| `null` / missing | ❌ No | Missing state key |
| `null` / missing identifier | ❌ No | Missing state key |

A missing identifier is a falsy operand, not a failure of the complete
expression. Therefore `<if condition="missingPath">` does not render, while
`<if condition="!missingPath">` does render. This applies to dotted paths in
loop scopes, such as `!item.optionalFlag`.

<webui-blockquote appearance="warning" title="Warning" icon="⚠️">

Expand Down
6 changes: 5 additions & 1 deletion docs/guide/concepts/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ Understanding the relationship between server and client is critical for buildin

### The server is the source of truth for the initial render

Every value bound in a template - `{{expression}}`, `<for each="item in items">`, `<if condition="expr">` - must have a corresponding key in the server state JSON. The handler resolves bindings by looking up keys in this JSON object. If a key is missing, the binding renders empty or the condition evaluates to false.
Every value bound in a template - `{{expression}}`, `<for each="item in items">`,
`<if condition="expr">` - should have a corresponding key in the server state
JSON. The handler resolves bindings by looking up keys in this object. Missing
text and attribute bindings render empty. A missing condition identifier is a
falsy operand, so its positive branch is hidden and its negated branch is shown.

### Derived state belongs in the server or the template

Expand Down
12 changes: 10 additions & 2 deletions docs/guide/concepts/state-management/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ The handler resolves paths using `find_value_by_dotted_path`. Supported patterns
| Array index | `items.0.label` | `"First"` |
| Array length | `items.length` | `2` |

Paths are resolved at render time. If a path doesn't exist in the state, the Rust handler treats it as a missing value: text and attribute bindings render as empty, and `<if>` conditions using that path evaluate to `false` (the block is not rendered). No error is reported for missing paths by default.
Paths are resolved at render time. If a path doesn't exist in the state, text
and attribute bindings render as empty. In conditions, a missing identifier is
a falsy operand: `<if condition="path">` does not render, while
`<if condition="!path">` does render. No error is reported for a missing
identifier path.

## State in Loops

Expand Down Expand Up @@ -122,7 +126,11 @@ The `<for>` directive iterates over arrays. Each item should be a self-contained

### Provide all state upfront

Unlike client-side frameworks that fetch data on mount, WebUI renders in a single pass. The state object should contain everything the template needs for first render. Missing values render as empty output (for text and attribute bindings) or evaluate to `false` (for `<if>` conditions) - no error is reported.
Unlike client-side frameworks that fetch data on mount, WebUI renders in a
single pass. The state object should contain everything the template needs for
first render. Missing values render as empty output for text and attribute
bindings. A missing condition identifier is falsy before logical operators are
applied, so its positive branch is hidden and its negated branch is shown.

```json
// ✅ Complete - every binding has data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,34 @@ test.describe('repeat conditional fixture', () => {
await expect(page.locator('test-repeat-conditional .link').first()).toHaveAttribute('data-href', '/search/shirts');
});

test('treats missing top-level and repeat-item paths as falsy operands', async ({ page }) => {
const ssr = page.locator('test-repeat-conditional');
await expect(ssr.locator('.missing-top-positive')).toHaveCount(0);
await expect(ssr.locator('.missing-top-negated')).toHaveText('missing top-level path is falsy');
await expect(ssr.locator('.missing-item-positive')).toHaveCount(0);
await expect(ssr.locator('.missing-item-negated')).toHaveText(['Shirts', 'Headwear', 'Archived']);

await page.evaluate(() => {
const client = document.createElement('test-repeat-conditional');
client.id = 'client-created-missing-paths';
document.body.appendChild(client);
});
await page.waitForFunction(() => {
const client = document.querySelector('#client-created-missing-paths');
return client && (client as any).$ready === true;
});

const client = page.locator('#client-created-missing-paths');
await expect(client.locator('.missing-top-positive')).toHaveCount(0);
await expect(client.locator('.missing-top-negated')).toHaveText('missing top-level path is falsy');
await expect(client.locator('.missing-item-positive')).toHaveCount(0);
await expect(client.locator('.missing-item-negated')).toHaveText(['Shirts', 'Headwear', 'Archived']);

await client.locator('.switch').click();
await expect(client.locator('.missing-item-positive')).toHaveCount(0);
await expect(client.locator('.missing-item-negated')).toHaveText(['Shirts', 'Headwear', 'Archived']);
});

test('re-evaluates repeat conditionals and boolean attrs on subsequent updates', async ({ page }) => {
await page.locator('test-repeat-conditional .load').click();
await page.locator('test-repeat-conditional .switch').click();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@
<button class="switch" @click="{switchItems()}">Switch</button>
<span class="selected-title">{{selectedTitle}}</span>
</div>
<if condition="missingTopLevel">
<span class="missing-top-positive">unexpected</span>
</if>
<if condition="!missingTopLevel">
<span class="missing-top-negated">missing top-level path is falsy</span>
</if>
<ul class="items">
<for each="item in items">
<li>
<if condition="item.searchPresentation">
<span class="missing-item-positive">{{item.title}}</span>
</if>
<if condition="!item.searchPresentation">
<span class="missing-item-negated">{{item.title}}</span>
</if>
<if condition="item.activeClass == 'active'">
<p class="current" data-href="{{item.href}}">{{item.title}}</p>
</if>
Expand Down
Loading