Skip to content
Draft
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
52 changes: 40 additions & 12 deletions skills/objectui/guides/data-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Connecting schema-driven rendering to a real or mock data backend. The DataSourc
│ Schema-Driven UI Layer │
│ (SchemaRenderer, Plugins) │
├─────────────────────────────┤
│ SchemaRendererProvider │ ← dataSource prop
│ SchemaRendererProvider │ ← dataSource prop (the ADAPTER)
├─────────────────────────────┤
│ DataSource Interface │ ← universal API contract
├─────────────────────────────┤
Expand All @@ -18,7 +18,15 @@ Connecting schema-driven rendering to a real or mock data backend. The DataSourc
└─────────────────────────────┘
```

Components never import fetch libraries directly. They access data through `useDataScope(path)` or the DataSource methods from context.
⚠️ That column is the FETCH channel and nothing else. The **values a page's
`${…}` expressions and `bind` paths read** arrive on a second, independent
channel — the ambient scope a host publishes with `PredicateScopeProvider`
(objectui#9308). `dataSource` publishes no expression root and answers no `bind`
path; keep the two apart when you wire a page.

Components never import fetch libraries directly. They call the DataSource
methods from context for CRUD, and read the ambient scope through
`useDataScope(path)`.

## DataSource interface

Expand Down Expand Up @@ -151,26 +159,43 @@ const dataSource = new ObjectStackAdapter({

### Static data (no backend)

For prototypes or static pages, pass a plain object as dataSource:
For prototypes or static pages there is no adapter to inject. Publish the values
as an ambient **scope** instead — that is the channel `bind` and `${…}` read:

<!-- os:check -->
```tsx
import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
import type { BaseSchema } from '@object-ui/types'

declare const schema: BaseSchema

```typescript
const staticData = {
customers: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
],
metrics: { total: 2, active: 1 },
userRole: 'admin',
};
}

<SchemaRendererProvider dataSource={staticData}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>
function Prototype() {
return (
<PredicateScopeProvider scope={staticData}>
<SchemaRenderer schema={schema} />
</PredicateScopeProvider>
)
}
```

Components that read `bind` (see "Via `bind` + `useDataScope`" below) will then
access `staticData.customers` when given `bind: "customers"`.

⛔ Passing that same object as `SchemaRendererProvider`'s `dataSource` does
**not** work and never warns: `dataSource` declares the `DataSource` adapter
contract, it publishes no expression root, and `useDataScope` stopped walking it
in objectui#9308. Every `bind` resolves `undefined` and every `${…}` on the
page renders as its own source characters.

## ObjectStackAdapter

The built-in adapter for ObjectStack backends (`packages/data-objectstack`).
Expand Down Expand Up @@ -217,8 +242,10 @@ A component reads the `bind` field only if it calls `useDataScope`:
}
```

Inside the component: `const data = useDataScope("customerNames")` resolves to
the `customerNames` array from the dataSource.
Inside the component: `const data = useDataScope("customerNames")` resolves the
`customerNames` array **from the ambient scope** a host published with
`PredicateScopeProvider` — not from `SchemaRendererProvider`'s `dataSource`
(objectui#9308).

`useDataScope` is called by `list` and `tree-view` in `@object-ui/components`,
and by the `object-*` widgets the plugin packages register (`object-grid`,
Expand Down Expand Up @@ -254,8 +281,9 @@ through is measured, with its open-question caveat, in
### Via expressions on the node

Computed values go through the expression system. `content` is the text key that
is both expression-evaluated and read back by the renderer, and the provider's
`dataSource` is reachable under the `data` root:
is both expression-evaluated and read back by the renderer. The roots it reads
are the ones the host published on `PredicateScopeProvider`, so the example below
assumes a scope carrying `data: { metrics: { total: … } }`:

<!-- os:check -->
```json
Expand Down
86 changes: 76 additions & 10 deletions skills/objectui/guides/schema-expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,78 @@ When the entire string is a single `${expression}`, the result preserves its typ

## Available scope variables

When expressions are evaluated, these variables are in scope:
Expression scope is published by the **host**, through `PredicateScopeProvider`.
Every key of the `scope` you hand it becomes a root the evaluator can read:

<!-- os:check -->
```tsx
import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
import type { BaseSchema } from '@object-ui/types'

declare const schema: BaseSchema

// Every name here becomes a root this page's expressions can read — `data`
// included, which is now a name YOU publish rather than one the renderer binds.
const scope = {
users: [{ id: 1, name: 'Ada Lovelace' }],
metrics: { total: 42 },
data: { fieldName: 'Ada Lovelace' },
}

function Page() {
return (
<PredicateScopeProvider scope={scope}>
<SchemaRenderer schema={schema} />
</PredicateScopeProvider>
)
}
```

| Variable | Source | Example |
|----------|--------|---------|
| Top-level data fields | `SchemaRendererProvider dataSource` | `${users}`, `${metrics.total}` |
| `data` | Alias for dataSource root | `${data.fieldName}` |
| `current_user` / `user` | Host predicate scope | `${current_user.email}` |
| every key of `scope` | the host's `PredicateScopeProvider` | `${users}`, `${metrics.total}` |
| `data` | a key of `scope` like any other — every `${data.*}` example on this page assumes a host that published one, as above | `${data.fieldName}` |
| `current_user` / `user` | the same channel; an app-shell host's `ExpressionProvider` already feeds it | `${current_user.email}` |
| `record` | the row a record surface is bound to, when there is one | `${record.status}` |
| `page` | Page-local state (`PageSchema.variables`) | `${page.selectedId}` |

That is the whole scope. There is **no `item` and no `index`** — the evaluator
context is built once per node, not once per array element. See "No per-item
template iteration" below.

> ### ⛔ `dataSource` is not an expression root, and this is not a renaming
>
> `SchemaRendererProvider`'s `dataSource` carries the host's `DataSource`
> **adapter** — the object a renderer calls `find()` on. The renderer used to
> publish that adapter under the name `data`. An adapter answers no `data.*`
> path an author would write, so that root was silently constant for every
> conformant host, and objectui#9308 removed it (maintainer ruling 2026-09-13).
>
> **Re-check every gate you authored from an older copy of this page: the
> verdict moved.** A root that is MISSING and a root that is PRESENT-but-empty
> are not the same thing, and the two layers that read `${…}` answer a missing
> root differently. Measured on the built evaluator with `data.status == 'draft'`:
>
> | what the scope holds | as a predicate (`visible` / `hidden`) | interpolated into a text key (`content`) |
> |---|---|---|
> | `data` bound to the adapter, which has no `status` member | `false` | `false` |
> | `data` present and `undefined` | `false` | `false` |
> | no `data` root at all — what you get now unless you publish one | **fails soft to `true`** | **the template's own source characters are printed on screen** |
>
> Read both columns. The predicate layer fails soft, so
> `"visible": "${data.status == 'draft'}"` written against the old wiring was
> **hidden on every row** and is now **shown on every row**; spelled `"hidden"`
> it flips the other way. The interpolation layer does not fail soft to
> anything — it hands back the characters you typed, so a `content` built from
> a missing root renders the literal text `${data.status == 'draft'}` to the
> user. objectui#5454's reporter warns about the predicate case.
>
> ⛔ Re-publishing `data` through `PredicateScopeProvider` restores the old,
> always-`false` verdict — it does not make the gate work. Give the gate a root
> that actually holds the row: at the runtime layer that root is **`record`**
> (ADR-0089 D3, whose `CANONICAL_ROOT_BY_LAYER` puts `record` at the runtime
> layer and `data` at the metadata layer).

### Safe globals (always available)
- `Math` — `${Math.round(price)}`, `${Math.max(a, b)}`
- `JSON` — `${JSON.stringify(obj)}`
Expand Down Expand Up @@ -298,11 +357,18 @@ The `bind` field is NOT expression-evaluated. It's a path string resolved by
}
```

When `SchemaRendererProvider` receives
`dataSource = { customerNames: ["Ada Lovelace", "Grace Hopper"] }`, `list` calls
`useDataScope("customerNames")` and renders one entry per array element.
When the host publishes
`scope = { customerNames: ["Ada Lovelace", "Grace Hopper"] }` through
`PredicateScopeProvider`, `list` calls `useDataScope("customerNames")` and
renders one entry per array element.

⛔ `bind` resolves against that same ambient scope — **not** against
`SchemaRendererProvider`'s `dataSource`. That prop is the `DataSource` adapter,
it has no member a `bind` path names, and objectui#9308 retired the walk over
it. A `bind` on a page with no scope published above it resolves `undefined`,
and each reader falls back to its own empty state.

**Nested paths work:** `"bind": "app.settings.users"` resolves `dataSource.app.settings.users`.
**Nested paths work:** `"bind": "app.settings.users"` resolves `scope.app.settings.users`.

### Which components read `bind`

Expand Down Expand Up @@ -390,7 +456,7 @@ section exists to close: binding `list` to ordinary records produces one empty

<!-- os:check -->
```jsonc
// ✅ Bound data, already node-shaped: dataSource = { rows: [{ "content": "Ada" }, { "content": "Linus" }] }
// ✅ Bound data, already node-shaped: scope = { rows: [{ "content": "Ada" }, { "content": "Linus" }] }
{ "type": "list", "bind": "rows" }
```

Expand Down Expand Up @@ -563,7 +629,7 @@ When an expression isn't working:

1. **Which key is it on, and does that type declare the key?** `content` and the predicate keys are evaluated and read on every type. `title` / `label` / `value` / `description` are evaluated **only on the types that declare them** — `statistic` (`label` / `value` / `description`), `card` (`title` / `description`), `button` (`label`) — and read raw everywhere else, including on a namespaced spelling such as `ui:statistic`. A `${...}` inside a `props` envelope is evaluated and then discarded. (A `properties` envelope is the one that is evaluated *and* hoisted onto the node — see [`rules/protocol.md`](../rules/protocol.md) for why that is recorded, not recommended.)
2. Is the `${}` syntax correct? Check for unmatched braces.
3. Is the data actually available in scope? Check `SchemaRendererProvider dataSource`.
3. Is the data actually available in scope? Check what the host published on `PredicateScopeProvider` — ⛔ not `SchemaRendererProvider`'s `dataSource`, which publishes no expression root.
4. For conditions: are you using `On` suffix correctly? (`hiddenOn` takes raw expression, `hidden` needs `${}` if it's a string).
5. Does the expression use a blocked pattern? Check for constructors, `eval`, `window`, etc.
6. Is type coercion causing issues? `${0 && "yes"}` returns `0`, not `false`.
23 changes: 18 additions & 5 deletions skills/objectui/rules/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ dropped.** The rule above is about `props`. `properties` is the spec spelling of
the same bag, and `SchemaRenderer` evaluates it and then **hoists every key onto
the node** (`type` / `id` excepted) before the renderer runs — so it is read by
every namespace, not just `element:*`. Measured on `origin/main` `f1c27f037`
with `dataSource = { label: "Evaluated Title" }`:
with a host scope carrying `data = { label: "Evaluated Title" }` (published
through `PredicateScopeProvider`). objectui#9308 retired the `dataSource`
wiring this was first measured through; the envelope readings below are
unchanged by that:

| node | rendered card header |
|---|---|
Expand Down Expand Up @@ -208,17 +211,27 @@ The `bind` field is NOT expression-evaluated. It's a path string resolved by `us
```jsonc
{
"type": "list",
"bind": "customerNames" // Resolved as dataSource.customerNames
"bind": "customerNames" // Resolved against the ambient scope
}
```

**Nested paths work:** `"bind": "app.settings.users"` resolves `dataSource.app.settings.users`.
**Nested paths work:** `"bind": "app.settings.users"` resolves `scope.app.settings.users`.

⛔ **The scope `bind` resolves against is the one a host publishes with
`PredicateScopeProvider`, not `SchemaRendererProvider`'s `dataSource`.** That
prop carries the `DataSource` **adapter** — it answers no `bind` path — and
objectui#9308 retired the walk over it. The same ruling stopped the renderer
publishing that adapter as the expression root `data`, so a `${data.*}` gate
authored before it now reads whatever the HOST published under `data`, and
nothing at all if the host published none. See "Available scope variables" in
[`../guides/schema-expressions.md`](../guides/schema-expressions.md) for the
verdict that move flips.

**Readers only.** `list` and `tree-view` (`@object-ui/components`) and the `object-*` plugin widgets call `useDataScope`. `data-table` does NOT: it reads its rows from an inline `data` array on the node, so a `bind` on it is ignored and the table renders its header over an empty body — no error, no warning.

**Provider rows into a `data-table`.** Measured on `origin/main` `f1c27f037`,
real `SchemaRenderer` inside a `SchemaRendererProvider` holding
`{ customers: [ 2 records ] }`, identical `columns` in every leg, reading
real `SchemaRenderer` under a host scope publishing
`data = { customers: [ 2 records ] }`, identical `columns` in every leg, reading
`tbody td`:

| node | rendered body cells |
Expand Down