diff --git a/README.md b/README.md
index 92e65c6a44..3ca74b840d 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 one line on the console.
+
### 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..29d9819081 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 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:
```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))
}
```
@@ -521,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
{
@@ -530,23 +580,32 @@ 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
-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 +626,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 +702,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 +715,10 @@ interface UserData {
}
}
-const data: UserData = { /* ... */ }
-
+const scope: AppScope = { /* ... */ }
+
+
+
```
## Next Steps