diff --git a/skills/objectui/guides/data-integration.md b/skills/objectui/guides/data-integration.md index 2e359383e3..e119a47d9a 100644 --- a/skills/objectui/guides/data-integration.md +++ b/skills/objectui/guides/data-integration.md @@ -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 ├─────────────────────────────┤ @@ -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 @@ -151,9 +159,16 @@ 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: + + +```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' }, @@ -161,16 +176,26 @@ const staticData = { ], metrics: { total: 2, active: 1 }, userRole: 'admin', -}; +} - - - +function Prototype() { + return ( + + + + ) +} ``` 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`). @@ -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`, @@ -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: … } }`: ```json diff --git a/skills/objectui/guides/schema-expressions.md b/skills/objectui/guides/schema-expressions.md index 3afe132c1e..bd2bf7c2fe 100644 --- a/skills/objectui/guides/schema-expressions.md +++ b/skills/objectui/guides/schema-expressions.md @@ -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: + + +```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 ( + + + + ) +} +``` | 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)}` @@ -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` @@ -390,7 +456,7 @@ section exists to close: binding `list` to ordinary records produces one empty ```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" } ``` @@ -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`. diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md index 9c39edab32..240192a8e3 100644 --- a/skills/objectui/rules/protocol.md +++ b/skills/objectui/rules/protocol.md @@ -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 | |---|---| @@ -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 |