From 5de354f8d3b9daa68b6db509807ace700a930440 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 08:27:05 +0000 Subject: [PATCH 1/3] docs: publish the page's own values through the scope channel, and name the three props the renderer never reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three remaining teaching surfaces from the objectui#8021 sweep — the root README, the expression guide and the architecture guide — each wrote the page's values onto the `SchemaRenderer` element and read them back under a bare head name. `SchemaRendererProps` declares exactly one prop, `schema`; `data`, `dataSource` and `debug` are forwarded to the component the schema names, so nothing throws, nothing warns, and the expression reaches the screen as the characters the author typed. Both coordinates move together on every site, per objectui#8021 leg D: * the six forwarded look-alikes become the provider that actually carries the value — `PredicateScopeProvider` for the expression scope, `SchemaRendererProvider` for the adapter and for `debug`; * the head names are judged one at a time against the scope the tree builds. Thirty-two stay bare, because after the objectui#9308 ruling the root set is open: every key the host publishes is a root. Five move to `record.*` — the two form-row sites and the three predicates written against a `form` root that nothing in ObjectUI publishes. `content/docs/guide/architecture.md` also carried the one `data.*` gate on these pages. The census could not see it — its classifier held `data` in the ACCEPTED root set — and it is exactly the spelling the ruling retires, so it moves to `record.age` and the guide states the verdict flip: a missing root and a present-but-undefined root are not the same, so a `data.*` gate that used to hide its node on every row now shows it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- README.md | 38 +++++++-- content/docs/guide/architecture.md | 38 +++++++-- content/docs/guide/expressions.md | 121 +++++++++++++++++++++-------- 3 files changed, 149 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 92e65c6a44..c5be86d184 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ npm install @object-ui/react @object-ui/components ```tsx import React from 'react' -import { SchemaRenderer } from '@object-ui/react' +import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' // Importing the package registers every default renderer as a side effect — // there is no separate registration call. import '@object-ui/components' @@ -92,16 +92,28 @@ const schema = { } function App() { - const data = { + // Every key of this object becomes a root the schema's expressions can read — + // `stats` here is what answers `${stats.users}`. + const scope = { stats: { users: 1234, revenue: "$56,789", orders: 432 } } - return + return ( + + + + ) } export default App ``` +Expression scope reaches the renderer through the provider, never through a prop on the +element. `SchemaRenderer` declares exactly one prop, `schema`, and forwards every other prop +it is handed to the component the schema names — so a value passed as `data={…}` is neither +read nor refused, and the expression that wanted it is returned as its own source text, with +nothing thrown and nothing logged. + ### Bring your own backend Use the shell and views without the full console infrastructure — your routing, your auth, your API: @@ -171,6 +183,10 @@ docs render, a smoke test mounts, and AI agents use as a few-shot corpus. ## Copy-Paste Schemas +A `${name.…}` in any of these reads the root `name` off the scope the host published — see +["Basic Usage"](#basic-usage) for the provider that publishes one. A head name nothing +published is not an error: the expression is returned as its own source text. + #### 📝 Contact Form ```json @@ -258,9 +274,9 @@ Object UI talks to any backend through one `DataSource` interface. npm install @object-ui/data-objectstack ``` -```typescript +```tsx import { createObjectStackAdapter } from '@object-ui/data-objectstack'; -import { SchemaRenderer } from '@object-ui/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; import type { BaseSchema } from '@object-ui/types'; // Your page schema — "Render a schema" above writes one out in full. @@ -271,10 +287,18 @@ const dataSource = createObjectStackAdapter({ token: 'your-auth-token' }); -// Use with any component - +// The adapter is injected through the provider — `SchemaRenderer` does not read +// a `dataSource` prop, it forwards it to the component the schema names. + + + ``` +⛔ `dataSource` is the **adapter** — the object data renderers call `find()` on — and not an +expression root. It is a different channel from the expression scope above: publish the values +your `${…}` expressions read with `PredicateScopeProvider`, and inject the adapter your data +components query with `SchemaRendererProvider`. + ### Custom Data Sources Adapt any backend (REST, GraphQL, Firebase, …) by implementing `DataSource`: diff --git a/content/docs/guide/architecture.md b/content/docs/guide/architecture.md index b596cb74e7..5ae2d81fc6 100644 --- a/content/docs/guide/architecture.md +++ b/content/docs/guide/architecture.md @@ -52,7 +52,7 @@ ObjectUI is organized as a PNPM monorepo with clear separation of concerns: - **Contains**: Schema validation, expression evaluation, registries - **Constraint**: No UI library dependencies, logic only - **Features**: - - Expression engine (`visible: "${data.age > 18}"`) + - Expression engine (`visible: "${record.age > 18}"`) - Schema registry and validation - Event system @@ -132,14 +132,19 @@ A backend system sends a JSON schema: The `SchemaRenderer` component: -1. Receives the schema + data context +1. Receives the schema, and reads the expression scope off the context above it 2. Evaluates expressions (`${user.name}`) 3. Looks up the component type in the registry 4. Recursively renders child schemas 5. Handles events and state updates +The scope does **not** arrive as a prop. `SchemaRenderer` declares exactly one prop, `schema`, +and forwards everything else it is handed to the component the schema names — so a `data={…}` +written on the element is neither read nor refused. The host publishes its values with +`PredicateScopeProvider`, and every key it publishes becomes a root the expressions can read: + ```tsx -import { SchemaRenderer } from '@object-ui/react' +import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' import type { BaseSchema } from '@object-ui/types' // The schema from step 1, as the object the renderer receives. @@ -153,12 +158,25 @@ const schema: BaseSchema = { } function App() { - const data = { user: { name: 'Alice' } } + const scope = { user: { name: 'Alice' } } - return + return ( + + + + ) } ``` +An app built on `@object-ui/app-shell` does not mount this provider itself: the shell's +`ExpressionProvider` already feeds the same channel with the signed-in `user` (also readable as +`current_user`) and `features`. On top of what the host published, the renderer supplies +`record` — the row a record surface is bound to — and `page`, the page-local variables. + +⛔ `SchemaRendererProvider`'s `dataSource` is **not** an expression root. It carries the host's +`DataSource` *adapter*, the object data renderers call `find()` on; the two are different +channels on purpose. + ### 3. Component Registry Lookup The registry maps type strings to React components: @@ -244,11 +262,17 @@ ObjectUI includes a powerful expression engine for dynamic UIs: { "type": "button", "label": "Submit", - "visible": "${form.isValid && !form.isSubmitting}", - "disabled": "${form.isSubmitting}" + "visible": "${current_user.role === 'admin'}", + "disabled": "${record.status === 'locked'}" } ``` +`current_user` is the signed-in user the host's `ExpressionProvider` publishes; `record` is the +row a record surface is bound to, and is the only spelling a row field has — the bare shorthand +(`status`) and the wrong-layer `data.status` were both retired on runtime record surfaces +(objectui#5330 phase 2). A head name outside the scope is not refused: the predicate is +unevaluable, this surface fails soft, and the node is shown on every row. + A button's text key is `label`, and `text` is not a `ButtonSchema` key at all. Nothing refuses the misspelling either: `BaseSchema` is `.passthrough()`, so the validator KEEPS the unknown key, and the renderer — which reads `schema.label` — never looks at it. diff --git a/content/docs/guide/expressions.md b/content/docs/guide/expressions.md index b890795763..d8f4776315 100644 --- a/content/docs/guide/expressions.md +++ b/content/docs/guide/expressions.md @@ -15,12 +15,13 @@ Expressions are JavaScript-like code snippets embedded in schemas using the `${} } ``` -With data: +With this published as the expression scope: ```tsx -const data = { user: { name: "Alice" } } +const scope = { user: { name: "Alice" } } ``` -This renders: **"Hello, Alice!"** +This renders: **"Hello, Alice!"** — `user` is a root because the host published a key by that +name. [Data Context](#data-context) below shows the provider that publishes one. ## Basic Syntax @@ -145,25 +146,44 @@ Disable component when expression is true: { "type": "button", "label": "Submit", - "disabledOn": "${form.submitting || !form.isValid}" + "disabledOn": "${record.status === 'submitted'}" } ``` ## Data Context -### Accessing Root Data +### Where the names come from -The root data object is available directly: +Expression scope does **not** arrive as a prop. `SchemaRenderer` declares exactly one prop, +`schema`, and forwards every other prop it is handed straight through to the component the +schema names — so a `data`, `dataSource` or `debug` written on the element is neither read nor +refused. Nothing throws and nothing warns; the expression simply never resolves, and an +unresolvable template is returned as its own source text, so the characters you typed are what +the reader sees. - +The host publishes its values with `PredicateScopeProvider`, and every key it publishes becomes +a root: ```tsx -const data = { - user: { name: "Alice" }, - settings: { theme: "dark" } +import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react' +import type { BaseSchema } from '@object-ui/types' + +// The page schema — your own document. +declare const schema: BaseSchema + +// Every name here becomes a root the schema's expressions can read. +const scope = { + user: { name: 'Alice' }, + settings: { theme: 'dark' }, } - +function App() { + return ( + + + + ) +} ``` ```json @@ -173,6 +193,29 @@ const data = { } ``` +The scope the evaluator builds is what you published, plus two names the renderer supplies: + +| name | what it holds | +|---|---| +| every key of `scope` | exactly what you put there — `user`, `settings`, whatever the page needs | +| `record` | the row a record surface is bound to, when there is one | +| `page` | page-local variables, for predicates that gate on another component's state | + +`current_user` is an alias of whatever you published as `user`; an app built on +`@object-ui/app-shell` does not mount the provider itself, because the shell's +`ExpressionProvider` already feeds the same channel with the signed-in `user` and `features`. + +> **`dataSource` is not an expression root.** `SchemaRendererProvider`'s `dataSource` carries +> the host's `DataSource` *adapter* — the object renderers call `find()` on. The renderer used +> to publish that adapter under the name `data`; an adapter answers no `data.*` path, so the +> root was constant for every conformant host, and objectui#9308 removed it. A `${data.…}` +> expression now reads whatever *you* published under `data`, and nothing if you published +> none. **This changes the verdict of a gate already in the field**: `visible: "${data.x}"` +> used to resolve to `undefined` and HIDE its node on every row; with no `data` key at all it +> is unevaluable, this surface fails soft, and the node is SHOWN. At the runtime layer the row +> is `record` (ADR-0089 D3) — rewrite such a gate to `record.*` rather than re-publishing a +> `data` key. + ### Scoped Data Some components provide scoped data: @@ -420,6 +463,12 @@ or author each variant and gate it with a condition key: ## Form Expressions +A form's own values are the **row under edit**, and the row is bound as `record` and nothing +else — the bare shorthand (`country`) and the wrong-layer `data.country` were both retired on +runtime record surfaces (objectui#5330 phase 2), and `record` is the canonical runtime-layer +root (ADR-0089 D3). There is no `form` root: a predicate written against one is unevaluable, +and a fail-soft surface answers it by showing the field on every row. + ### Dependent Fields ```json @@ -436,7 +485,7 @@ or author each variant and gate it with a condition key: "type": "select", "name": "state", "label": "State/Province", - "visibleOn": "${form.country === 'USA'}", + "visibleOn": "${record.country === 'USA'}", "options": ["CA", "NY", "TX"] } ] @@ -467,7 +516,7 @@ evaluated on every component type: ```json { "type": "text", - "content": "Total: ${form.price * form.quantity}" + "content": "Total: ${record.price * record.quantity}" } ``` @@ -484,13 +533,13 @@ Expressions are re-evaluated when data changes. Avoid expensive operations: "content": "${users.map(u => expensiveOperation(u)).join(', ')}" } -// ✅ Good: Pre-compute in data +// ✅ Good: Pre-compute, and publish the result ``` ```tsx -const data = { +const scope = { processedUsers: users.map(u => expensiveOperation(u)) } ``` @@ -534,19 +583,21 @@ Error: "Cannot read property 'invalidProperty' of undefined" ### Debug Mode -Enable debug mode to see expression evaluation: +`debug` is read off the same provider context as `dataSource` +(`context?.debug || context?.debugFlags?.enabled`), never off the element — a `debug` written +on `SchemaRenderer` is forwarded to the component the schema names, exactly like a `data` prop, +and turns nothing on. Mount the provider instead: - + ```tsx - + + + ``` -This logs all expression evaluations to the console. +This logs all expression evaluations to the console. It is orthogonal to the expression scope: +wrap this pair in a `PredicateScopeProvider` as well when you want both. ## Advanced Usage @@ -567,12 +618,12 @@ const formatCurrency = (value: number) => evaluateExpression('${formatCurrency(price)}', { formatCurrency, price: 1234.5 }) ``` -That is the direct-evaluation path. A component expression rendered by -`SchemaRenderer` resolves against the scope the renderer itself builds — the -provider's data source (as `data`), the host scope (`user` / `current_user`) and -page variables — so a function you registered elsewhere is not reachable from a -schema expression. Compute the value before it reaches the schema, and bind the -result. +That is the direct-evaluation path, and `formatCurrency` is a root there because this call +hands the evaluator its own context. A component expression rendered by `SchemaRenderer` +resolves against a different scope — the names the host published through +`PredicateScopeProvider`, plus `record` and `page` — so a function you registered elsewhere is +not reachable from a schema expression. Compute the value before it reaches the schema, and +bind the result. Hold an evaluator when you want one context reused — construct it, then call `evaluate`: @@ -643,12 +694,12 @@ language's own. A membership test is written with the array method: ### 4. Use TypeScript -Define your data types: +Define the type of the scope you publish: - + ```tsx -interface UserData { +interface AppScope { user: { name: string role: 'admin' | 'user' @@ -656,8 +707,10 @@ interface UserData { } } -const data: UserData = { /* ... */ } - +const scope: AppScope = { /* ... */ } + + + ``` ## Next Steps From 20831dc1dc63965b7ea792e62b48780bc96ac478 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 08:36:28 +0000 Subject: [PATCH 2/3] docs(expressions): state what an unresolvable expression actually does, measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Expression Errors block claimed `${user.invalidProperty}` yields `Cannot read property 'invalidProperty' of undefined`. objectui#9297's addendum flagged that as looking wrong and explicitly left it NOT MEASURED. Measured now, on the built evaluator, and it is wrong in both directions: { user: { name: 'Alice' } } ${user.invalidProperty} => undefined {} (no user root) ${user.invalidProperty} => "${user.invalidProperty}" control, same instrument: ${user.name} => "Alice" Nothing is thrown on either path. A missing MEMBER of a published root renders as nothing; a missing ROOT renders as the characters the author typed, with one console line. Repaired here rather than filed because the same commit series now states, two sections up, that an unresolvable template is returned as its own source text — leaving the old sentence would have made the page contradict itself. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- content/docs/guide/expressions.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/content/docs/guide/expressions.md b/content/docs/guide/expressions.md index d8f4776315..decd501ca5 100644 --- a/content/docs/guide/expressions.md +++ b/content/docs/guide/expressions.md @@ -570,7 +570,8 @@ All expression outputs are automatically sanitized to prevent XSS attacks. ### Expression Errors -Invalid expressions show helpful error messages: +An expression that cannot be resolved is **not** an error the reader sees, and it is not the +same failure in both directions. Measured on the built evaluator: ```json { @@ -579,7 +580,14 @@ Invalid expressions show helpful error messages: } ``` -Error: "Cannot read property 'invalidProperty' of undefined" +| the scope | what the evaluator returns | +|---|---| +| `user` is published, `invalidProperty` is not a member of it | `undefined` — nothing is thrown | +| no `user` root at all | the template's own **source text**, and one line on the console | + +So a missing member renders as nothing, and a missing root renders as the characters you +typed. Neither raises, and neither stops the render — which is why the scope a page publishes +has to be stated rather than assumed. ### Debug Mode From 049a797d4c0be05b95cff221d593b0b4e4e3dc18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 14:41:16 +0000 Subject: [PATCH 3/3] docs: the missing-root path prints one console line, so say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sentences this branch added taught silence on a path that is not silent. Measured just now on the built evaluator (`packages/core/dist`, console captured, one instance per leg): {} (no user root) ${user.name} => "${user.name}" 1 console.warn { user: { name: 'Alice' } } ${user.invalidProperty} => undefined 0 lines { user: { name: 'Alice' } } ${user.name} => "Alice" 0 lines harness control console.warn(sentinel) => fires 1 line The line is `Failed to evaluate expression: ...` from `reportEvaluationFault` in `packages/core/src/evaluator/ExpressionEvaluator.ts`, which falls back to `console.warn` whenever the caller supplies no `onFault`. There is no dev-only gate on it, and the six `evaluator.evaluate(...)` call sites in `packages/react/src/SchemaRenderer.tsx` that resolve `content`, `properties.*` and `props.*` all pass no options, so the doc-relevant path is exactly the built-in warn. The one-per-authored-source ceiling in that same function is why the count is one line and not one per render. This branch already stated the true half two sections down in the expression guide's Expression Errors table — "the template's own source text, and one line on the console" — so the page contradicted itself inside one diff. Both sentences now carry that same phrasing rather than new wording. `content/docs/guide/schema-rendering.md` carries the same claim at the same head; it is outside this pull request's file set and is left byte-identical. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- README.md | 2 +- content/docs/guide/expressions.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c5be86d184..3ca74b840d 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Expression scope reaches the renderer through the provider, never through a prop element. `SchemaRenderer` declares exactly one prop, `schema`, and forwards every other prop it is handed to the component the schema names — so a value passed as `data={…}` is neither read nor refused, and the expression that wanted it is returned as its own source text, with -nothing thrown and nothing logged. +nothing thrown and one line on the console. ### Bring your own backend diff --git a/content/docs/guide/expressions.md b/content/docs/guide/expressions.md index decd501ca5..29d9819081 100644 --- a/content/docs/guide/expressions.md +++ b/content/docs/guide/expressions.md @@ -157,9 +157,9 @@ Disable component when expression is true: Expression scope does **not** arrive as a prop. `SchemaRenderer` declares exactly one prop, `schema`, and forwards every other prop it is handed straight through to the component the schema names — so a `data`, `dataSource` or `debug` written on the element is neither read nor -refused. Nothing throws and nothing warns; the expression simply never resolves, and an -unresolvable template is returned as its own source text, so the characters you typed are what -the reader sees. +refused. Nothing throws, and there is one line on the console; the expression simply never +resolves, and an unresolvable template is returned as its own source text, so the characters +you typed are what the reader sees. The host publishes its values with `PredicateScopeProvider`, and every key it publishes becomes a root: