diff --git a/DESIGN.md b/DESIGN.md index fed82576e..fcadf6c05 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 @@ -646,6 +649,8 @@ pub fn evaluate(condition: &ConditionExpr, state: &Value) -> Result, <, ==, !=, >=, <= 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 @@ -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` diff --git a/crates/webui-expressions/src/lib.rs b/crates/webui-expressions/src/lib.rs index fd9ee4aed..d54226654 100644 --- a/crates/webui-expressions/src/lib.rs +++ b/crates/webui-expressions/src/lib.rs @@ -39,7 +39,10 @@ pub enum ExpressionError { pub type Result = std::result::Result; -/// 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 { evaluate_with_resolver(condition, |path| find_value_by_dotted_path_ref(path, state)) } @@ -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( @@ -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" ); } diff --git a/crates/webui-handler/src/lib.rs b/crates/webui-handler/src/lib.rs index 4ec63c26b..2fa62ec7c 100644 --- a/crates/webui-handler/src/lib.rs +++ b/crates/webui-handler/src/lib.rs @@ -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, @@ -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#"top-positivetop-negated{{item.label}}{{item.label}}"#; + 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-negatedNormalSearch" + ); + } + #[test] fn test_handle_component() { // Create a protocol with a component diff --git a/docs/ai/SKILL.md b/docs/ai/SKILL.md index 64921357d..38a93c11e 100644 --- a/docs/ai/SKILL.md +++ b/docs/ai/SKILL.md @@ -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 `` 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 @@ -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, `` 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 diff --git a/docs/guide/concepts/best-practices.md b/docs/guide/concepts/best-practices.md index 8ccd3e9b1..2fb94dab6 100644 --- a/docs/guide/concepts/best-practices.md +++ b/docs/guide/concepts/best-practices.md @@ -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}}`, ``, and `` in your template and ensure the server provides the data. diff --git a/docs/guide/concepts/directives/if.md b/docs/guide/concepts/directives/if.md index f73b6febd..517c80900 100644 --- a/docs/guide/concepts/directives/if.md +++ b/docs/guide/concepts/directives/if.md @@ -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 `` does not render, while +`` does render. This applies to dotted paths in +loop scopes, such as `!item.optionalFlag`. diff --git a/docs/guide/concepts/how-it-works.md b/docs/guide/concepts/how-it-works.md index 7dfc15f8e..503b8732b 100644 --- a/docs/guide/concepts/how-it-works.md +++ b/docs/guide/concepts/how-it-works.md @@ -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}}`, ``, `` - 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}}`, ``, +`` - 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 diff --git a/docs/guide/concepts/state-management/index.md b/docs/guide/concepts/state-management/index.md index 0202ac551..6c5adb52c 100644 --- a/docs/guide/concepts/state-management/index.md +++ b/docs/guide/concepts/state-management/index.md @@ -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 `` 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: `` does not render, while +`` does render. No error is reported for a missing +identifier path. ## State in Loops @@ -122,7 +126,11 @@ The `` 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 `` 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 diff --git a/packages/webui-framework/tests/fixtures/repeat-conditional/repeat-conditional.spec.ts b/packages/webui-framework/tests/fixtures/repeat-conditional/repeat-conditional.spec.ts index 67e4a3793..7b0f201b9 100644 --- a/packages/webui-framework/tests/fixtures/repeat-conditional/repeat-conditional.spec.ts +++ b/packages/webui-framework/tests/fixtures/repeat-conditional/repeat-conditional.spec.ts @@ -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(); diff --git a/packages/webui-framework/tests/fixtures/repeat-conditional/src/test-repeat-conditional/test-repeat-conditional.html b/packages/webui-framework/tests/fixtures/repeat-conditional/src/test-repeat-conditional/test-repeat-conditional.html index c0be95b1a..418010d8f 100644 --- a/packages/webui-framework/tests/fixtures/repeat-conditional/src/test-repeat-conditional/test-repeat-conditional.html +++ b/packages/webui-framework/tests/fixtures/repeat-conditional/src/test-repeat-conditional/test-repeat-conditional.html @@ -3,9 +3,21 @@ {{selectedTitle}} + + unexpected + + + missing top-level path is falsy +
  • + + {{item.title}} + + + {{item.title}} +

    {{item.title}}