diff --git a/.changeset/9308-data-root-unbound-from-adapter.md b/.changeset/9308-data-root-unbound-from-adapter.md
new file mode 100644
index 0000000000..d8d707ccd5
--- /dev/null
+++ b/.changeset/9308-data-root-unbound-from-adapter.md
@@ -0,0 +1,64 @@
+---
+'@object-ui/react': minor
+'@object-ui/components': minor
+'@object-ui/types': minor
+---
+
+Unbind the data-source adapter from the expression scope, and point `bind` at the scope
+channel (objectui#9308, maintainer ruling 2026-09-13, option B).
+
+**Breaking, deliberately — and `minor` only because this repo's `fixed` group of 40
+packages may not carry a `major` (AGENTS.md 版本号策略).** Read the migration note below
+before upgrading if any of your metadata reads `data.*` at the page/component tier.
+
+**What changed.**
+
+1. `SchemaRenderer` no longer writes `data: dataSource` into the evaluator scope. `data`
+ is not a root this tier binds. The roots it supplies are `record` (the row, when a
+ record surface bound one), `page` (page-local variables) and `current_user` — plus
+ every name the host published through `PredicateScopeProvider`.
+2. `useDataScope(path)` — what a node's `bind` resolves through — reads the ambient
+ predicate scope (`usePredicateScope()`) instead of walking the injected adapter.
+
+**Who this breaks, concretely.**
+
+* **A `${data.…}` expression on a page/component node.** It used to resolve against the
+ host's injected `DataSource` adapter. An adapter answers no `data.*` path, so for every
+ host that injected a real adapter the read was already `undefined` and the predicate was
+ a silent constant — but the constant MOVES: `data` used to be a present key holding an
+ object, so `data.status == 'draft'` evaluated cleanly to `false` and the node was hidden
+ on every row. `data` is now ABSENT, the expression cannot be evaluated at all, and this
+ surface is fail-soft, so the same gate now answers `true` and the node is SHOWN on every
+ row. It is no longer silent: the objectui#5454 reporter names it in the console, in
+ production as well as development. Re-root such a predicate on `record.*` — the row —
+ or publish a `data` of your own through `PredicateScopeProvider`.
+* **A host that injected a plain data bag as `dataSource`.** That was meaning 2 of the
+ key, and it stops working entirely: `${data.…}` no longer reads it and `bind` no longer
+ walks it. It has been a compile error since objectui#7912 (`dataSource` is typed
+ `DataSource | null | undefined`). Publish those values through `PredicateScopeProvider`
+ instead — the provider, the hook and the app-shell wiring already exist, and this change
+ adds no new published key.
+* **The nine `bind` readers** (`list`, `tree-view`, `ObjectChart`, `ObjectDataTable`,
+ `ObjectPivotTable`, `ObjectGrid`, `ObjectKanban`, `ObjectGallery`, `ObjectTimeline`) now
+ resolve `bind` against the scope. All nine already carry a fallback
+ (`boundData || schema.items`, `|| schema.nodes || []`, or a fall-through to their own
+ fetch chain), and against a conformant adapter `useDataScope` returned `undefined`
+ before this change — so the fallback is what was running, and a `bind` that resolved
+ nothing before still resolves nothing. What is new is that a `bind` CAN now resolve:
+ a host that publishes rows on its scope will see them used where the fetch used to run.
+
+**Why.** `@object-ui/app-shell`'s `ExpressionProvider` states the rule this applies: every
+root bound at a tier must be one the engine accepts AND one that tier can actually answer.
+objectui#8155 unbound `app` under it and objectui#8166 unbound `data`; ADR-0089 D3 puts
+`data` at the metadata layer and `record` at the runtime layer, and the engine's
+per-surface `FIELD_RULE_BOUND_ROOTS` is `['record','previous','parent']`. The renderer tier
+was the last one still binding a root it could not answer.
+
+Two side effects worth naming. A host that legitimately published `data` through the
+documented scope channel used to be silently overwritten by the adapter (the adapter was
+spread last); it is not any more. And `reportAdapterOnlyDataPredicate` now resolves against
+the `data` the evaluator actually bound rather than against the adapter — left pointing at
+the adapter it would have gone silent for precisely the hosts this change breaks.
+
+The Data Context passages of `content/docs/guide/schema-rendering.md` and
+`packages/react/README.md` teach the scope channel accordingly.
diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md
index e9d036aab1..5c7940074b 100644
--- a/content/docs/guide/schema-rendering.md
+++ b/content/docs/guide/schema-rendering.md
@@ -68,7 +68,7 @@ interface BaseSchema {
"visibleOn": "${user.role === 'admin'}",
"body": {
"type": "text",
- "content": "Total Users: ${data.stats.totalUsers}"
+ "content": "Total Users: ${stats.totalUsers}"
}
}
```
@@ -79,52 +79,63 @@ Expression context does not arrive as a prop. `SchemaRenderer` declares exactly
`schema`, and every other prop it is handed is forwarded to the component the schema names —
so a `data` prop written on the element reaches the evaluator through nothing. Because it is
forwarded rather than refused, nothing throws and nothing warns; the expression simply never
-resolves. The scope comes from `SchemaRendererProvider`, which publishes its `dataSource`
-under the name `data`:
+resolves. The scope comes from `PredicateScopeProvider`, which publishes each name you give
+it as an expression root:
```tsx
-import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'
+import { SchemaRenderer, PredicateScopeProvider } from '@object-ui/react'
import type { BaseSchema } from '@object-ui/types'
// The page schema from the first example on this page.
declare const schema: BaseSchema
-const dataSource = {
+// Every name here becomes a root the schema's expressions can read.
+const scope = {
user: { name: 'John', role: 'admin' },
stats: { totalUsers: 1234 },
}
function App() {
return (
-
+
-
+
)
}
```
-The scope the evaluator builds holds four names, and nothing else:
+An app built on `@object-ui/app-shell` does not mount this provider itself: the shell's
+`ExpressionProvider` already feeds the same channel with `user` (the signed-in user, also
+readable as `current_user`) and `features`.
+
+The scope the evaluator builds is what you published, plus three names the renderer supplies:
| name | what it holds |
|---|---|
-| `data` | the `dataSource` the provider above published — everything you passed in |
+| every key of `scope` | exactly what you put there — `user`, `stats`, whatever the page needs |
| `page` | page-local variables, for predicates that gate on another component's state |
| `record` | the row a record surface is bound to, when there is one |
-| `current_user` (aliased to `user`) | the signed-in user, published by the host's `ExpressionProvider` — not by anything on this page |
+| `current_user` | an alias of whatever you published as `user`; the host's `ExpressionProvider` publishes the signed-in user there |
A name outside that set resolves to nothing, and an unresolvable template is not an error:
the evaluator hands back its own source text, so the characters you typed are what the reader
sees.
+> **`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. At the runtime layer the row is `record` (ADR-0089).
+
### Accessing Data in Schemas
-Use expression syntax `${}` to reference the scope, and reach your own values through the
-`data.` prefix:
+Use expression syntax `${}` to reference the scope, by the name you published it under:
```json
{
"type": "text",
- "content": "Welcome, ${data.user.name}!"
+ "content": "Welcome, ${user.name}!"
}
```
@@ -409,11 +420,11 @@ const pageSchema = {
### 2. Use Data Context Effectively
-Put everything the schema's expressions need on one `dataSource`, mounted above the tree —
-not on the renderer, which does not read it:
+Put everything the schema's expressions need on one scope, mounted above the tree — not on
+the renderer, which does not read it:
```tsx
-import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'
+import { SchemaRenderer, PredicateScopeProvider } from '@object-ui/react'
import type { BaseSchema } from '@object-ui/types'
// The reader's own values.
@@ -422,8 +433,8 @@ declare const userData: { name: string }
declare const userSettings: { theme: string }
declare const dashboardStats: { totalUsers: number }
-// ✅ Good — one provider, and every expression reaches it through `data.`
-const dataSource = {
+// ✅ Good — one provider, and every expression reads a name published on it
+const scope = {
user: userData,
settings: userSettings,
stats: dashboardStats,
@@ -431,9 +442,9 @@ const dataSource = {
function Dashboard() {
return (
-
+
-
+
)
}
```
diff --git a/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx b/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
index 47bc4d6759..93da9bbcf8 100644
--- a/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
+++ b/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx
@@ -33,7 +33,7 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
-import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import { SchemaRenderer, SchemaRendererProvider, PredicateScopeProvider } from '@object-ui/react';
// Module scope, not a hook — the cold transform would otherwise be billed to
// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
import '../renderers';
@@ -55,9 +55,11 @@ const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month
const renderNode = (schema: any) =>
render(
-
+
+
- ,
+
+ ,
);
describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
diff --git a/packages/components/src/__tests__/data-table-node-data-diagnostic.test.tsx b/packages/components/src/__tests__/data-table-node-data-diagnostic.test.tsx
index 9f0dadf407..0f2a91332f 100644
--- a/packages/components/src/__tests__/data-table-node-data-diagnostic.test.tsx
+++ b/packages/components/src/__tests__/data-table-node-data-diagnostic.test.tsx
@@ -42,7 +42,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
-import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import { SchemaRenderer, SchemaRendererProvider, PredicateScopeProvider } from '@object-ui/react';
// The REAL renderers, imported at module scope so `data-table` is in the
// registry before the first render (AGENTS.md §测试纪律 — never behind a lazy
@@ -114,9 +114,11 @@ function warningsOn(prefix: string): string[] {
function tree(schema: unknown) {
return (
-
+
+
+
);
}
diff --git a/packages/components/src/__tests__/disabled-verdict-one-carrier.test.tsx b/packages/components/src/__tests__/disabled-verdict-one-carrier.test.tsx
index 98c92467e0..bdee708a23 100644
--- a/packages/components/src/__tests__/disabled-verdict-one-carrier.test.tsx
+++ b/packages/components/src/__tests__/disabled-verdict-one-carrier.test.tsx
@@ -30,7 +30,7 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
-import { SchemaRenderer, SchemaRendererContext } from '@object-ui/react';
+import { SchemaRenderer, SchemaRendererContext, PredicateScopeProvider } from '@object-ui/react';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout` (objectui#3010/#3021).
import '../renderers';
@@ -38,9 +38,11 @@ import '../renderers';
/** `${data.locked}` resolves against this — the `dataSource` on the context. */
function renderNode(schema: Record, locked: boolean) {
return render(
-
+
+
- ,
+
+ ,
);
}
diff --git a/packages/components/src/__tests__/guide-schema-rendering-data-context-8021.test.tsx b/packages/components/src/__tests__/guide-schema-rendering-data-context-8021.test.tsx
index 4435fb1c8e..d26b8522db 100644
--- a/packages/components/src/__tests__/guide-schema-rendering-data-context-8021.test.tsx
+++ b/packages/components/src/__tests__/guide-schema-rendering-data-context-8021.test.tsx
@@ -14,32 +14,44 @@
* `SchemaRendererProps` declares exactly ONE prop, `schema`
* (`packages/react/src/SchemaRenderer.tsx`). The docblock beside it names the
* mechanism: the renderer "passes every prop it does not itself read straight
- * through to the component the schema names". So a `data={…}` written on a
- * `SchemaRenderer` element is not ignored and does not warn — it is FORWARDED,
- * and the evaluator never sees it.
+ * through to the component the schema names". So a `data={…}` or `scope={…}`
+ * written on a `SchemaRenderer` element is not ignored and does not warn — it
+ * is FORWARDED, and the evaluator never sees it.
*
- * The evaluator's scope is built from `usePredicateScope()` plus
- * `current_user`, an optional `record`, `data: dataSource` and
- * `page: pageVariables`, where `dataSource` comes from
- * `SchemaRendererProvider`. So a host's own values are reachable only under the
- * `data.` prefix, and a bare `${user.…}` addresses the AMBIENT signed-in user
- * an `ExpressionProvider` publishes — never the object the page handed over.
+ * The evaluator's scope is `usePredicateScope()` plus `current_user`, an
+ * optional `record` and `page: pageVariables`. So a host's own values are
+ * reachable under whatever NAME it published them through
+ * `PredicateScopeProvider`, and a `${data.…}` addresses a root the renderer no
+ * longer binds at all.
*
* Two coordinates, and the page had both wrong. A repair that moves only one
- * leaves it broken, which is why legs B and D below are LIT CONTROLS rather
+ * leaves it broken, which is why legs B, D and E below are LIT CONTROLS rather
* than commentary:
*
- * | leg | wiring | expression | rendered |
- * |-----|-----------------------|-----------------------|-------------------------|
- * | A | what the page teaches | what the page teaches | must be `Welcome, John!`|
- * | B | `data` prop | `data.` prefix | `Welcome, !` |
- * | C | provider `dataSource` | `data.` prefix | `Welcome, John!` |
- * | D | provider `dataSource` | bare `user.` | the raw source text |
+ * | leg | wiring | expression | rendered |
+ * |-----|-------------------------|-----------------------|-------------------------|
+ * | A | what the page teaches | what the page teaches | must be `Welcome, John!`|
+ * | B | `scope` prop on element | the page's expression | the raw source text |
+ * | C | `PredicateScopeProvider`| `${user.…}` | `Welcome, John!` |
+ * | D | `PredicateScopeProvider`| `${data.user.…}` | the raw source text |
+ * | E | provider `dataSource` | `${data.user.…}` | the raw source text |
*
* B proves the prop supplies nothing even when the expression is right; D
- * proves the prefix is needed even when the wiring is right. Leg A is not
- * transcribed from the page — it is READ OFF the page on every run, so it can
- * only go green when both coordinates have moved.
+ * proves the name matters even when the wiring is right; E is objectui#9308's
+ * own coordinate — the ADAPTER seam is not an expression root, so the wiring
+ * this page used to teach now renders the characters the author typed. Leg A
+ * is not transcribed from the page — it is READ OFF the page on every run, so
+ * it can only go green when both coordinates have moved.
+ *
+ * ## objectui#9308 — what moved here, and why this file had to move with it
+ *
+ * The maintainer ruling of 2026-09-13 (option B) removed `data: dataSource`
+ * from the evaluator scope and pointed `useDataScope` at the scope channel.
+ * The teaching this file pins was meaning 2 of that one key — publish page
+ * values through `dataSource`, read them back under `data.*` — so the pages
+ * moved and this pin was RE-DERIVED to the new teaching rather than relaxed.
+ * Leg C's wiring and leg D's expression are the two halves that swapped; leg E
+ * is new and is the direct pin on the removal.
*
* ## Why leg A is derived rather than listed
*
@@ -65,22 +77,9 @@ import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import '../renderers';
-import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import { SchemaRenderer, SchemaRendererProvider, PredicateScopeProvider } from '@object-ui/react';
import type { DataSource } from '@object-ui/types';
-/**
- * NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context
- * it feeds — declare the published `DataSource` adapter contract. The values
- * this file injects are deliberately NOT adapters —
- * `SCOPE` is the `data` ROOT of the expression scope, which is exactly what
- * this document's Data Context passage teaches and what legs A/C/D measure:
- * the renderer binds `SchemaRendererContext.dataSource` as `data` for every
- * predicate, the second meaning this one key carries (objectui#9308).
- * Each injection therefore crosses the contract with an explicit
- * `as unknown as DataSource`. Every injected value is byte-for-byte what it
- * was before: this marks the crossing, it changes no assertion.
- */
-
/**
* Anchored on this file's own naked `import.meta.url`, never on
* `process.cwd()`: the cwd differs between a repo-root vitest invocation and a
@@ -100,26 +99,49 @@ const ROOT = repoRoot();
const GUIDE = 'content/docs/guide/schema-rendering.md';
const readDoc = (rel: string): string => readFileSync(join(ROOT, rel), 'utf8');
-/** The host scope every leg is given: as a `dataSource`, or as the `data` prop. */
+/**
+ * The host values every leg is given, published under the names the guide now
+ * teaches: as a `scope`, as the `scope` prop, or as a `dataSource`.
+ */
const SCOPE = { user: { name: 'John', role: 'admin' }, stats: { totalUsers: 1234 } };
const textNode = (content: string) => ({ type: 'text', content });
/**
- * Leg A/B wiring: the prop the page used to teach, with no provider above it.
+ * Leg B wiring: the prop written on the element, with no provider above it.
*
* ⭐ No cast is needed, and that is itself the measurement: `SchemaRenderer`'s
* declared type is `SchemaRendererProps & Record`, the open
- * forwarding surface. TypeScript therefore ACCEPTS `data` here — the prop is
+ * forwarding surface. TypeScript therefore ACCEPTS `scope` here — the prop is
* not rejected anywhere, at compile time or at runtime. It is simply handed to
* whatever component the schema names.
*/
-function renderWithDataProp(content: string): string {
- return render().container.textContent ?? '';
+function renderWithScopeProp(content: string): string {
+ return render().container.textContent ?? '';
}
/** Leg C/D wiring: the provider that actually seeds the evaluator. */
-function renderWithProvider(content: string): string {
+function renderWithScopeProvider(content: string): string {
+ return (
+ render(
+
+
+ ,
+ ).container.textContent ?? ''
+ );
+}
+
+/**
+ * Leg E wiring: the ADAPTER seam.
+ *
+ * NOTE (objectui#7912): `SchemaRendererProvider.dataSource` declares the
+ * published `DataSource` adapter contract, and the value below is deliberately
+ * NOT an adapter — it is the page-values bag the guide used to teach putting
+ * there. The crossing is explicit, and it is the subject of this leg rather
+ * than an inconvenience: objectui#9308 is exactly the ruling that this seam is
+ * an adapter injection point and not an expression root.
+ */
+function renderWithAdapter(content: string): string {
return (
render(
@@ -156,22 +178,24 @@ function firstFence(src: string, lang: string): string {
*
* `\b` after the name is what keeps `SchemaRendererProvider` out of the set:
* the character after `SchemaRenderer` there is `P`, a word character, so the
- * boundary does not match. The provider is the CORRECT carrier for these props
- * and must not be counted as an offender.
+ * boundary does not match. The providers are the CORRECT carriers for these
+ * props and must not be counted as offenders.
*/
function schemaRendererElements(src: string): string[] {
return src.match(//g) ?? [];
}
/**
- * The three props that LOOK like evaluator wiring and are not: `SchemaRenderer`
- * reads none of them. `data` and `dataSource` reach the evaluator only through
- * `SchemaRendererProvider`; `debug` is read off the same context
- * (`SchemaRenderer.tsx`: `context?.debug || context?.debugFlags?.enabled`).
- * Written on the element they are forwarded to whatever component the schema
- * names — silently.
+ * The four props that LOOK like evaluator wiring and are not: `SchemaRenderer`
+ * reads none of them. `scope` reaches the evaluator only through
+ * `PredicateScopeProvider`; `data` and `dataSource` reach nothing at all on
+ * this element (`dataSource` on `SchemaRendererProvider` is the host's ADAPTER
+ * and, since objectui#9308, not an expression root anywhere); `debug` is read
+ * off the renderer context (`SchemaRenderer.tsx`:
+ * `context?.debug || context?.debugFlags?.enabled`). Written on the element
+ * they are forwarded to whatever component the schema names — silently.
*/
-const FORWARDED_LOOKALIKES = /\b(data|dataSource|debug)=\{/;
+const FORWARDED_LOOKALIKES = /\b(data|dataSource|debug|scope)=\{/;
/**
* The teaching surfaces this repair covers IN FULL. Not a glob: each document
@@ -212,28 +236,43 @@ describe('objectui#8021 leg A — the guide’s own pair, read off the page', ()
'SchemaRenderer',
);
- const teachesProvider = / {
- it('leg B: the `data` prop supplies nothing even with the `data.` prefix', () => {
- expect(renderWithDataProp('Welcome, ${data.user.name}!')).toBe('Welcome, !');
+describe('objectui#8021 legs B–E — the controls that keep the errors apart', () => {
+ it('leg B: a `scope` prop on the element supplies nothing', () => {
+ // Unresolvable, so the evaluator hands back its own SOURCE TEXT — which is
+ // the failure the reader actually sees on the page.
+ expect(renderWithScopeProp('Welcome, ${user.name}!')).toBe('Welcome, ${user.name}!');
+ });
+
+ it('leg C: the provider plus a published name is the green wiring', () => {
+ expect(renderWithScopeProvider('Welcome, ${user.name}!')).toBe('Welcome, John!');
});
- it('leg C: provider `dataSource` plus the `data.` prefix is the green wiring', () => {
- expect(renderWithProvider('Welcome, ${data.user.name}!')).toBe('Welcome, John!');
+ it('leg D: the right wiring still prints raw source for a `${data.…}` read', () => {
+ // Nothing published `data` here, and since objectui#9308 the renderer
+ // publishes none of its own, so the template is unresolvable.
+ expect(renderWithScopeProvider('Welcome, ${data.user.name}!')).toBe('Welcome, ${data.user.name}!');
});
- it('leg D: the right wiring still prints raw source for a bare `${user.…}`', () => {
- // Nothing published `user` here, so the template is unresolvable and the
- // evaluator hands back its own SOURCE TEXT — the failure the reader sees.
- expect(renderWithProvider('Welcome, ${user.name}!')).toBe('Welcome, ${user.name}!');
+ it('leg E: the ADAPTER seam is not an expression root (objectui#9308)', () => {
+ // The wiring this page used to teach, with the expression it used to teach.
+ // Both were moved by the same ruling, and this is the leg that says so.
+ expect(renderWithAdapter('Welcome, ${data.user.name}!')).toBe('Welcome, ${data.user.name}!');
});
});
diff --git a/packages/components/src/__tests__/skill-guide-data-table-binding.test.tsx b/packages/components/src/__tests__/skill-guide-data-table-binding.test.tsx
index ad853f790a..2b98eb67c4 100644
--- a/packages/components/src/__tests__/skill-guide-data-table-binding.test.tsx
+++ b/packages/components/src/__tests__/skill-guide-data-table-binding.test.tsx
@@ -66,7 +66,7 @@ import React from 'react';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import { SchemaRenderer, SchemaRendererProvider, PredicateScopeProvider } from '@object-ui/react';
// The REAL renderers, imported at module scope so `data-table` / `list` are in
// the registry before the first render (AGENTS.md §测试纪律 — never behind a
@@ -165,9 +165,11 @@ function bindWarnings(): string[] {
function renderNode(schema: unknown, dataSource: unknown) {
return render(
-
+ }>
+
- ,
+
+ ,
);
}
diff --git a/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx b/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx
index ff32965d56..05a66996a8 100644
--- a/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx
+++ b/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx
@@ -58,7 +58,7 @@ import React from 'react';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import { SchemaRenderer, SchemaRendererProvider, PredicateScopeProvider } from '@object-ui/react';
// The REAL renderers, at module scope so `data-table` / `card` are registered
// before the first render (AGENTS.md §测试纪律). Relative, not the bare
@@ -103,9 +103,11 @@ const EMPTY_STATE = 'No results foundTry adjusting your filters or search query.
function renderNode(schema: unknown) {
return render(
-
+
+
- ,
+
+ ,
);
}
diff --git a/packages/components/src/renderers/__tests__/shadowed-renderer-behaviour.test.tsx b/packages/components/src/renderers/__tests__/shadowed-renderer-behaviour.test.tsx
index da7c526527..2e0bdfa41c 100644
--- a/packages/components/src/renderers/__tests__/shadowed-renderer-behaviour.test.tsx
+++ b/packages/components/src/renderers/__tests__/shadowed-renderer-behaviour.test.tsx
@@ -28,7 +28,7 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
-import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import { SchemaRenderer, SchemaRendererProvider, PredicateScopeProvider } from '@object-ui/react';
// Module-scope side-effect import, not a `beforeAll` — see
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../index';
@@ -72,9 +72,11 @@ const DATA_SOURCE = {
const renderBound = (schema: Record) =>
render(
-
+
+
- ,
+
+ ,
);
describe('`table` keeps exactly the behaviour it has today (objectui#5125)', () => {
diff --git a/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts b/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts
index 654079235c..2a11e8af7a 100644
--- a/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts
+++ b/packages/components/src/renderers/complex/dataTableBindDiagnostic.ts
@@ -50,10 +50,20 @@
*
* It changes NO behaviour. `data-table` still does not read `bind`, and the
* ruling is explicit that it must not start: making it a `useDataScope` reader
- * (option B) is a separate published-surface question needing its own ruling,
- * including a `data`-vs-`bind` precedence. Refusing the key at parse (option C)
- * stays blocked on the `.passthrough()` ceiling (objectui#5155 / objectui#6269).
- * So the trap stops being silent; it does not stop being a trap.
+ * (option B) is a separate published-surface question needing its own ruling.
+ * Refusing the key at parse (option C) stays blocked on the `.passthrough()`
+ * ceiling (objectui#5155 / objectui#6269). So the trap stops being silent; it
+ * does not stop being a trap.
+ *
+ * ⭐ The `data`-vs-`bind` PRECEDENCE half of that open question DISSOLVED at
+ * objectui#9308 rather than being answered. While `useDataScope` walked the
+ * injected adapter, `bind` and the node's own `data` were two spellings of one
+ * idea — "where this table's rows come from" — and a reader of both would have
+ * needed a rule. Since that ruling `bind` resolves a path in the ambient
+ * predicate scope and `data` is an inline array on the node: different
+ * channels, different questions, no precedence to decide. objectui#6575's
+ * ruling that `data-table` must not read `bind` is untouched by that, and this
+ * diagnostic's job is unchanged.
*
* ## Why a console warning, and only a console warning
*
diff --git a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx
index 470d3e61b4..e0dcafe5f7 100644
--- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx
@@ -50,7 +50,7 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
-import { SchemaRendererProvider } from '@object-ui/react';
+import { SchemaRendererProvider, PredicateScopeProvider } from '@object-ui/react';
import React from 'react';
const captured = vi.hoisted(() => ({ schemas: [] as any[] }));
@@ -114,9 +114,13 @@ const BOUND_SCHEMA = {
function renderBound() {
return render(
-
-
-
+ {/* objectui#9308 — `bind` resolves the ambient predicate scope. The
+ adapter seam stays mounted, and is inert for these three legs. */}
+
+
+
+
+
,
);
}
diff --git a/packages/plugin-grid/src/__tests__/columnSpellingDiagnosticRender.test.tsx b/packages/plugin-grid/src/__tests__/columnSpellingDiagnosticRender.test.tsx
index 518ec6e73c..25455b67d7 100644
--- a/packages/plugin-grid/src/__tests__/columnSpellingDiagnosticRender.test.tsx
+++ b/packages/plugin-grid/src/__tests__/columnSpellingDiagnosticRender.test.tsx
@@ -35,7 +35,7 @@ import React from 'react';
import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
-import { ActionProvider, SchemaRendererProvider } from '@object-ui/react';
+import { ActionProvider, SchemaRendererProvider, PredicateScopeProvider } from '@object-ui/react';
registerAllFields();
@@ -63,7 +63,14 @@ function renderGrid(
render(
{options.scopeData !== undefined
- ? {grid}
+ ? (
+ // objectui#9308 — `bind` resolves the ambient predicate scope, not the
+ // injected adapter. The adapter seam is still crossed (it is what a
+ // real host mounts) and is inert for this assertion.
+ }>
+ {grid}
+
+ )
: grid}
,
);
diff --git a/packages/react/README.md b/packages/react/README.md
index d09ebb3d13..56b5f28e59 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -38,13 +38,18 @@ function App() {
### With Data
-Expression scope reaches the renderer through `SchemaRendererProvider`, never through a prop
+Expression scope reaches the renderer through `PredicateScopeProvider`, never through a prop
on the element: `SchemaRenderer` declares only `schema`, so anything else is forwarded to the
-component the schema names (see the open forwarding surface below). The provider's
-`dataSource` is what the evaluator sees under the name `data`.
+component the schema names (see the open forwarding surface below). Every key of the `scope`
+you publish becomes a root the evaluator can read.
+
+⛔ `SchemaRendererProvider`'s `dataSource` is **not** an expression root. It carries the
+host's `DataSource` adapter, and objectui#9308 removed the binding that used to publish that
+adapter under the name `data` — an adapter answers no `data.*` path, so the root was constant
+for every conformant host.
```tsx
-import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'
+import { SchemaRenderer, PredicateScopeProvider } from '@object-ui/react'
const schema = {
type: 'form',
@@ -54,7 +59,7 @@ const schema = {
// the spec's expression carriage map, so a `${…}` in ITS `value` would be
// rendered as those characters rather than resolved.
type: 'text',
- content: 'Editing ${data.user.name}'
+ content: 'Editing ${user.name}'
},
{
type: 'input',
@@ -64,15 +69,15 @@ const schema = {
]
}
-const dataSource = {
+const scope = {
user: { name: 'John Doe' }
}
function App() {
return (
-
+
-
+
)
}
```
@@ -146,14 +151,18 @@ host's authentication.
### useSchemaContext
Access what `SchemaRendererProvider` injected — `dataSource`, `debug`,
-`debugFlags` and `apiFetch`. It does **not** carry the record data: read that
-with `useDataScope`, which addresses the current data scope by path.
+`debugFlags` and `apiFetch`. It does **not** carry the page's values: read those
+with `useDataScope`, which addresses the ambient expression scope by path. The
+two are different channels on purpose — `dataSource` is the adapter a renderer
+queries, `useDataScope` is what a node's `bind` resolves against
+(objectui#9308).
```tsx
import { useDataScope, useSchemaContext } from '@object-ui/react'
function MyComponent() {
const { dataSource, debug } = useSchemaContext()
+ // Resolves `scope.value` from the nearest PredicateScopeProvider above.
const value = useDataScope('value')
if (!dataSource) return null
diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx
index 67a602afec..99d226a18d 100644
--- a/packages/react/src/SchemaRenderer.tsx
+++ b/packages/react/src/SchemaRenderer.tsx
@@ -823,11 +823,33 @@ export const SchemaRenderer: ForwardRefExoticComponent<
// an object spread.
if (!schema || typeof schema !== 'object') return schema;
- // `data` (record/datasource) plus the ambient host scope. `current_user`
- // is aliased to `user` so both `user.email` and `current_user.email`
- // resolve in component `visible`/`visibleOn` expressions. `page` exposes
- // page-local state so predicates can gate on `page.` (e.g. a record
- // picker's selection toggling another component's visibility).
+ // The ambient host scope, plus the roots this tier can answer itself.
+ // `current_user` is aliased to `user` so both `user.email` and
+ // `current_user.email` resolve in component `visible`/`visibleOn`
+ // expressions. `page` exposes page-local state so predicates can gate on
+ // `page.` (e.g. a record picker's selection toggling another
+ // component's visibility).
+ //
+ // ⛔ `data` is NOT here, and the absence is the decision (objectui#9308,
+ // maintainer ruling 2026-09-13 option B). This used to read
+ // `data: dataSource` — the host's injected ADAPTER, published as an
+ // expression root. `ExpressionProvider` states the governing principle for
+ // the tier above: "Every root below is one the engine accepts AND one this
+ // tier can actually answer", and objectui#8155 (`app`) and objectui#8166
+ // (`data`) applied it there. Against a conformant `DataSource` adapter
+ // every `data.*` path resolves `undefined`, so this tier could not answer
+ // the root it bound: it published a name that was silently constant on
+ // every row. ADR-0089 D3 puts `data` at the METADATA layer
+ // (`CANONICAL_ROOT_BY_LAYER = { runtime: 'record', metadata: 'data' }`) and
+ // the engine's per-surface `FIELD_RULE_BOUND_ROOTS` is
+ // `['record','previous','parent']`. The row is `record`.
+ //
+ // ⭐ Ordering consequence, and the second half of the same ruling: the
+ // spread below used to be followed by `data: dataSource`, so a host that
+ // legitimately published `data` through the documented scope channel
+ // (`PredicateScopeProvider`) was silently OVERWRITTEN by the adapter.
+ // Removing the line un-shadows that channel — a host root named `data` now
+ // survives, like every other root a host publishes.
//
// `record` is written AFTER the ambient spread so a page's own row wins
// over anything a host put in the scope — the same precedence
@@ -844,7 +866,6 @@ export const SchemaRenderer: ForwardRefExoticComponent<
...(boundRecord && typeof boundRecord === 'object' && !Array.isArray(boundRecord)
? { record: boundRecord }
: null),
- data: dataSource,
page: pageVariables,
});
// Shallow copy
@@ -959,7 +980,19 @@ export const SchemaRenderer: ForwardRefExoticComponent<
// `false`. Verdict untouched — `verdict` is returned exactly as
// computed, which is what keeps the ruling's "no verdict changes" true
// by construction rather than by review.
- reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, dataSource);
+ //
+ // objectui#9308: the object handed over is the `data` the HOST
+ // published in the ambient scope — the one the evaluator above
+ // actually resolved `data.*` against — and no longer the adapter. The
+ // renderer binds no `data` of its own, so passing the adapter here
+ // would report reads the evaluator never made against it.
+ reportAdapterOnlyDataPredicate(
+ newSchema.type,
+ newSchema.id,
+ key,
+ raw,
+ (predicateScope as any)?.data,
+ );
return verdict;
} catch (err) {
reportUnresolvableVisibilityPredicate(
@@ -1073,7 +1106,15 @@ export const SchemaRenderer: ForwardRefExoticComponent<
},
});
if (__DEV__ && !faulted) {
- reportAdapterOnlyDataPredicate(newSchema.type, newSchema.id, key, raw, dataSource, 'enablement');
+ // objectui#9308 — same re-aim as the visibility leg above.
+ reportAdapterOnlyDataPredicate(
+ newSchema.type,
+ newSchema.id,
+ key,
+ raw,
+ (predicateScope as any)?.data,
+ 'enablement',
+ );
}
return verdict;
};
diff --git a/packages/react/src/__tests__/SchemaRenderer.aliasPrecedenceCrossChannel.test.tsx b/packages/react/src/__tests__/SchemaRenderer.aliasPrecedenceCrossChannel.test.tsx
index 8ae4b29cbf..3c4cfbbf58 100644
--- a/packages/react/src/__tests__/SchemaRenderer.aliasPrecedenceCrossChannel.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.aliasPrecedenceCrossChannel.test.tsx
@@ -48,6 +48,7 @@ import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
const DATA = { total: 99, label: 'Widgets' };
@@ -95,9 +96,11 @@ const CrossChannelProbe = ({ schema, ...reactProps }: any) => {
const renderWithData = (schema: any) =>
render(
-
+
+
+
);
/** Both channels of one node, as the issue's probe printed them. */
diff --git a/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx b/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx
index c3e58e14d3..3b65b6fd71 100644
--- a/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx
@@ -54,6 +54,7 @@ import {
} from '@objectstack/spec/ui';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
const DATA = { total: 99, name: 'Widgets', note: 'since last week' };
@@ -92,9 +93,11 @@ afterEach(() => {
const renderNode = (schema: any) =>
render(
-
+
+
- ,
+
+ ,
);
const read = (key: string) => screen.getByTestId('probe').getAttribute(`data-${key}`);
diff --git a/packages/react/src/__tests__/SchemaRenderer.dataRootUnbound-9308.test.tsx b/packages/react/src/__tests__/SchemaRenderer.dataRootUnbound-9308.test.tsx
new file mode 100644
index 0000000000..06c200ccf6
--- /dev/null
+++ b/packages/react/src/__tests__/SchemaRenderer.dataRootUnbound-9308.test.tsx
@@ -0,0 +1,202 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#9308 — the data-source ADAPTER stops being an expression root, and
+ * `bind` starts resolving the scope channel instead.
+ *
+ * Maintainer ruling, 2026-09-13, option B, in two halves:
+ *
+ * b1. `data: dataSource` leaves the evaluator scope `SchemaRenderer` builds.
+ * `data` is no longer a root this tier binds.
+ * b2. `useDataScope` stops walking `context.dataSource` and reads
+ * `usePredicateScope()` — the ambient scope a host publishes through
+ * `PredicateScopeProvider`, which app-shell's `ExpressionProvider`
+ * already feeds.
+ *
+ * ## Why this is not a new direction
+ *
+ * `ExpressionProvider` states the governing principle for the tier above:
+ * "Every root below is one the engine accepts AND one this tier can actually
+ * answer". objectui#8155 unbound `app` under it and objectui#8166 unbound
+ * `data`. Against a conformant `DataSource` adapter every `data.*` path already
+ * resolved `undefined`, so the renderer tier could not answer the root it
+ * bound. This file applies the same principle one tier down.
+ *
+ * ## The legs, and what each one would have to break to go green wrongly
+ *
+ * Legs 1 and 2 are a PAIR on one coordinate: the injected object is the same
+ * bag in both, and only the CHANNEL it arrives through moves. An
+ * implementation that simply stopped resolving expressions would pass leg 1
+ * and fail leg 2; one that kept the adapter bound would fail leg 1 and pass
+ * leg 2. Neither can pass both.
+ *
+ * Leg 3 is the shadowing the ruling's own appendix named: `...predicateScope`
+ * was spread BEFORE `data: dataSource`, so a host that legitimately published
+ * `data` through the documented scope channel was silently overwritten by the
+ * adapter. b1 removes the overwrite, and this leg is the only place that says
+ * so.
+ *
+ * Legs 4-6 are the same coordinate pair for `useDataScope`, plus its own lit
+ * control (a hook that returned `undefined` unconditionally would pass leg 5
+ * and fail legs 4 and 6).
+ *
+ * Leg 7 records a VERDICT MOVE, measured rather than assumed. `data` was a key
+ * whose value happened to be undefined-at-every-path; it is now ABSENT. The
+ * engine distinguishes the two: `data.status == 'draft'` against
+ * `{ data: undefined }` is a clean `false`, and against a scope with no `data`
+ * key at all it THROWS `data is not defined` — which `evaluateCondition`
+ * answers fail-soft with `true`. So a node whose `visible` gate read `data.*`
+ * was hidden on every row before this card and is SHOWN on every row after it,
+ * and the objectui#5454 reporter — which is the loud one, and the true one —
+ * names it. That is the breaking half of this change, and it is pinned here
+ * rather than discovered in a console.
+ *
+ * Module-scope imports, never `beforeAll` (AGENTS.md 测试纪律): registering a
+ * renderer is an unbounded module load and must not be billed to a bounded
+ * hook timeout.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, renderHook, screen, cleanup } from '@testing-library/react';
+import React from 'react';
+import { ComponentRegistry } from '@object-ui/core';
+import { SchemaRenderer } from '../SchemaRenderer';
+import {
+ __resetVisibilityPredicateWarnings,
+ UNRESOLVABLE_VISIBILITY_PREFIX,
+} from '../utils/visibilityDiagnostic';
+import {
+ SchemaRendererProvider,
+ useDataScope,
+} from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
+import type { DataSource } from '@object-ui/types';
+
+const NAME = 'probe-9308';
+const TYPE = 'element:probe-9308';
+
+const Probe = (props: { content?: unknown }) => (
+
+);
+
+/**
+ * ONE bag, injected through two different channels by legs 1 and 2.
+ *
+ * It is deliberately NOT an adapter: crossing `DataSource` with an explicit
+ * cast is what makes leg 1 a measurement of the SEAM rather than of a type.
+ * A host that puts a bag here is the population this card breaks, and leg 1 is
+ * the pin that says it breaks.
+ */
+const BAG = { stats: { total: 99 } };
+
+const spyWarn = () => vi.spyOn(console, 'warn').mockImplementation(() => {});
+const linesWith = (warn: { mock: { calls: unknown[][] } }, prefix: string): string[] =>
+ warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes(prefix));
+
+beforeEach(() => {
+ ComponentRegistry.register(NAME, Probe as never, { namespace: 'element', skipFallback: true } as never);
+ __resetVisibilityPredicateWarnings();
+});
+afterEach(() => {
+ cleanup();
+ ComponentRegistry.unregister?.(NAME, 'element');
+ vi.restoreAllMocks();
+});
+
+/** Render one node under the ADAPTER seam only. */
+function underAdapter(content: string) {
+ return render(
+
+
+ ,
+ );
+}
+
+/** Render one node under the SCOPE channel only. */
+function underScope(content: string, scope: Record = BAG) {
+ return render(
+
+
+ ,
+ );
+}
+
+const contentOf = () => screen.getByTestId('probe').getAttribute('data-content');
+
+describe('objectui#9308 b1 — the adapter is not an expression root', () => {
+ it('leg 1: `${data.stats.total}` against an injected bag does NOT resolve', () => {
+ underAdapter('${data.stats.total}');
+ // Unresolvable: the evaluator hands back its own SOURCE TEXT, which is the
+ // failure an author can actually see. `99` here would mean the adapter is
+ // still bound as `data`.
+ expect(contentOf()).toBe('${data.stats.total}');
+ });
+
+ it('leg 2: the SAME bag through the scope channel resolves under its own name', () => {
+ underScope('${stats.total}');
+ expect(contentOf()).toBe('99');
+ });
+
+ it('leg 3: a host-published `data` is no longer shadowed by the adapter', () => {
+ // Both channels carry a `data`, and they disagree. Before this card the
+ // adapter's spread came LAST and won; the documented scope channel must.
+ render(
+
+
+
+
+ ,
+ );
+ expect(contentOf()).toBe('7');
+ });
+});
+
+describe('objectui#9308 b2 — `useDataScope` reads the scope channel', () => {
+ it('leg 4: a path resolves against the ambient scope', () => {
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ const { result } = renderHook(() => useDataScope('stats.total'), { wrapper });
+ expect(result.current).toBe(99);
+ });
+
+ it('leg 5: the same path against the ADAPTER seam resolves to nothing', () => {
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ const { result } = renderHook(() => useDataScope('stats.total'), { wrapper });
+ expect(result.current).toBeUndefined();
+ });
+
+ it('leg 6: lit control — no path still means no value, scope or not', () => {
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ expect(renderHook(() => useDataScope(undefined), { wrapper }).result.current).toBeUndefined();
+ expect(renderHook(() => useDataScope(''), { wrapper }).result.current).toBeUndefined();
+ });
+});
+
+describe('objectui#9308 — the verdict this card MOVES, stated as a pin', () => {
+ it('leg 7: a `data.*` visibility gate becomes unresolvable — shown, and LOUD', () => {
+ const warn = spyWarn();
+ render(
+
+
+ ,
+ );
+ // Fail-soft: `evaluateCondition` answers an unevaluable predicate `true`.
+ // Before this card the same gate was a clean constant `false` and the node
+ // was hidden on every row.
+ expect(screen.queryByTestId('probe')).not.toBeNull();
+ // …and it is not silent. objectui#5454's reporter is the one that is TRUE
+ // about this predicate now: it cannot be evaluated at all.
+ expect(linesWith(warn, UNRESOLVABLE_VISIBILITY_PREFIX).length).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/react/src/__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx b/packages/react/src/__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx
index 48b755fc83..a07353c2eb 100644
--- a/packages/react/src/__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx
@@ -79,15 +79,18 @@ import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
/** The provider really does hold the path the object-bag case spells. */
const DATA = { customers: ['ada', 'grace'] };
const renderWithData = (schema: unknown) =>
render(
-
+
+
- ,
+
+ ,
);
const snap = (v: unknown) => JSON.parse(JSON.stringify(v ?? null));
diff --git a/packages/react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx b/packages/react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx
index 3ce2e226a6..47f201b42e 100644
--- a/packages/react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx
@@ -58,6 +58,7 @@ import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
import {
DROPPED_PROPS_BAG_PREFIX,
__resetDroppedPropsBagWarnings,
@@ -68,9 +69,11 @@ const DATA = { customers: ['ada', 'grace'] };
const renderWithData = (schema: unknown) =>
render(
-
+
+
- ,
+
+ ,
);
const snap = (v: unknown) => JSON.parse(JSON.stringify(v ?? null));
diff --git a/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx b/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx
index ee70f86461..548f767739 100644
--- a/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx
@@ -82,6 +82,7 @@ import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
import type { DataSource } from '@object-ui/types';
+import { PredicateScopeProvider } from '../hooks/useExpression';
/**
* NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context
@@ -110,9 +111,11 @@ const DATA = { status: 'locked', readOnly: true, unlocked: false };
function renderNode(schema: Record) {
return render(
-
+
+
- ,
+
+ ,
);
}
diff --git a/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx
index bb9df6cda9..b66f0e04a7 100644
--- a/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx
@@ -85,13 +85,30 @@ const Probe = (props: { disabled?: unknown }) => (
/>
);
-/** The ambient scope app-shell's `ExpressionProvider` really mounts. */
+/**
+ * A HOST scope that publishes its own `data` root through
+ * `PredicateScopeProvider`.
+ *
+ * ⚠️ NOT what app-shell's `ExpressionProvider` mounts — the comment that said
+ * so was stale: `buildExpressionScope` has published no `data` since
+ * objectui#8166. Since objectui#9308 the renderer binds no `data` either, so a
+ * host publication is the only way the adapter-only leg is reachable at all
+ * (with no `data` anywhere the predicate throws and objectui#5454's reporter
+ * takes it instead).
+ */
const APP_SCOPE = {
current_user: { id: 'u1' },
user: { id: 'u1' },
data: {},
features: {},
};
+/** The same host scope, publishing a `data` root that DOES answer. */
+const scopeWithData = (data: Record) => ({ ...APP_SCOPE, data });
+/**
+ * The injected adapter. Inert since objectui#9308 — kept so every mount still
+ * crosses the real seam, and so a reverted re-aim would be visible rather than
+ * silently green.
+ */
const ADAPTER = { total: 99 };
const ROW = { id: 'r1', status: 'open' };
@@ -188,7 +205,7 @@ const nonValidatorWarnings = (warn: WarnSpy): string[] =>
* measuring the previous case's leakage rather than this case's behaviour.
*/
async function inProduction(
- fn: (mount: (schemas: Record[]) => void) => void | Promise,
+ fn: (mount: (schemas: Record[], scope?: Record) => void) => void | Promise,
): Promise {
vi.resetModules();
vi.stubEnv('NODE_ENV', 'production');
@@ -206,9 +223,9 @@ async function inProduction(
namespace: 'element',
skipFallback: true,
} as never);
- const mount = (schemas: Record[]) =>
+ const mount = (schemas: Record[], scope: Record = APP_SCOPE) =>
render(
-
+
{schemas.map((s, i) => (
@@ -229,7 +246,7 @@ async function inProduction(
/** Mount one schema in the ordinary (development) module graph. */
async function inDevelopment(
- fn: (mount: (schemas: Record[]) => void) => void | Promise,
+ fn: (mount: (schemas: Record[], scope?: Record) => void) => void | Promise,
): Promise {
const [core, dev, ctx, rec, expr, diag] = await Promise.all([
import('@object-ui/core'),
@@ -245,9 +262,9 @@ async function inDevelopment(
skipFallback: true,
} as never);
try {
- const mount = (schemas: Record[]) =>
+ const mount = (schemas: Record[], scope: Record = APP_SCOPE) =>
render(
-
+
{schemas.map((s, i) => (
@@ -668,10 +685,11 @@ describe('#6504 group 6 — the adapter-only diagnostic, extended to `disabled`
});
});
- it('a GENUINE adapter read on `disabled` stays silent — the half that makes the noise mean something', async () => {
+ it('a GENUINE answered read on `disabled` stays silent — the half that makes the noise mean something', async () => {
await inDevelopment((mount) => {
const warn = spyWarn();
- mount([{ id: 'n1', disabled: 'data.total > 0' }]); // ADAPTER.total === 99
+ // objectui#9308 — answered by the HOST-published `data`, not the adapter.
+ mount([{ id: 'n1', disabled: 'data.total > 0' }], scopeWithData({ total: 99 }));
expect(disabledProp()).toBe('true'); // a REAL verdict, from a real read
expect(adapterOnlyReports(warn)).toHaveLength(0);
});
diff --git a/packages/react/src/__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx b/packages/react/src/__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx
index c06d9212a3..e5fe464956 100644
--- a/packages/react/src/__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.enablementEnvelopeConfigBag.test.tsx
@@ -72,6 +72,7 @@ import { ComponentRegistry, ExpressionEvaluator } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
import { useCondition, toPredicateInput } from '../hooks/useExpression';
+import { PredicateScopeProvider } from '../hooks/useExpression';
const DATA = { status: 'draft' };
@@ -133,9 +134,11 @@ const Probe = (props: { schema?: Record; disabled?: boolean })
function mount(schema: unknown) {
return render(
-
+
+
- ,
+
+ ,
);
}
diff --git a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx
index a638a818da..fd72f56664 100644
--- a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx
@@ -24,6 +24,7 @@ import { SchemaRenderer } from '../SchemaRenderer';
// with them — nothing in this file needs the name any more.
import { SchemaRendererContext } from '../context/SchemaRendererContext';
import type { DataSource } from '@object-ui/types';
+import { PredicateScopeProvider } from '../hooks/useExpression';
/**
* NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context
@@ -66,18 +67,22 @@ describe('SchemaRenderer Expression Integration', () => {
it('evaluates visible expression string', () => {
render(
+
+
);
expect(screen.getByTestId('test-component')).toBeInTheDocument();
});
it('hides when visible expression evaluates to false', () => {
const { container } = render(
+
+
);
expect(container.innerHTML).toBe('');
});
@@ -94,9 +99,11 @@ describe('SchemaRenderer Expression Integration', () => {
it('hides with hiddenOn expression', () => {
const { container } = render(
+
+
);
expect(container.innerHTML).toBe('');
});
@@ -112,27 +119,33 @@ describe('SchemaRenderer Expression Integration', () => {
describe('visibleWhen (ADR-0089 canonical)', () => {
it('shows when the visibleWhen predicate is truthy', () => {
render(
+
+
);
expect(screen.getByTestId('test-component')).toBeInTheDocument();
});
it('hides when the visibleWhen predicate is falsy', () => {
const { container } = render(
+
+
);
expect(container.innerHTML).toBe('');
});
it('still honors the deprecated `visibility` alias', () => {
const { container } = render(
+
+
);
expect(container.innerHTML).toBe('');
});
@@ -146,27 +159,33 @@ describe('SchemaRenderer Expression Integration', () => {
it('evaluates disabled expression string', () => {
render(
+
+
);
expect(screen.getByTestId('test-component')).toHaveAttribute('data-disabled', 'true');
});
it('does not set disabled when expression is false', () => {
render(
+
+
);
expect(screen.getByTestId('test-component')).not.toHaveAttribute('data-disabled');
});
it('evaluates disabledOn expression', () => {
render(
+
+
);
expect(screen.getByTestId('test-component')).toHaveAttribute('data-disabled', 'true');
});
diff --git a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx
index 630b348974..7691da1276 100644
--- a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx
@@ -92,6 +92,7 @@ import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema, DataSource } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
/**
* NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context
@@ -120,9 +121,11 @@ const DATA = { status: 'draft', archived: true, published: false };
function renderNode(schema: Record) {
return render(
-
+
+
- ,
+
+ ,
);
}
@@ -151,9 +154,11 @@ function renderNode(schema: Record) {
*/
function renderDeclaredNode(schema: BaseSchema) {
return render(
-
+
+
- ,
+
+ ,
);
}
diff --git a/packages/react/src/__tests__/SchemaRenderer.nodeGateDataPredicate.test.tsx b/packages/react/src/__tests__/SchemaRenderer.nodeGateDataPredicate.test.tsx
index 8011c3dc2d..63b81fa718 100644
--- a/packages/react/src/__tests__/SchemaRenderer.nodeGateDataPredicate.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.nodeGateDataPredicate.test.tsx
@@ -49,6 +49,23 @@
* guard instead turns RED the silence cases (2a/2b/2c/3a/3b) while group 1 stays
* green, which is the false-positive direction the ruling refuses.
*
+ * ## objectui#9308 — WHICH object `data` names moved; the triple did not
+ *
+ * The 2026-08-22 ruling above said the node tier "keeps its documented
+ * `data` = adapter semantics". The 2026-09-13 ruling on objectui#9308 retired
+ * exactly that: the renderer binds no `data` root, and the only `data` at this
+ * tier is one a HOST published through `PredicateScopeProvider`. The three
+ * cases this docblock names are unchanged in SHAPE — repro reports, `record.*`
+ * stays silent, an answered read stays silent — but the object that answers,
+ * and the object the diagnostic resolves against, is now that host `data`.
+ * `ADAPTER` is still injected on every mount and is inert; group 2c is what
+ * keeps the re-aim honest, because it moves a key the adapter never has.
+ *
+ * The population with NO `data` anywhere — the real app-shell shape — does not
+ * reach this file's diagnostic at all: the predicate throws `data is not
+ * defined` and objectui#5454's reporter takes it. That leg, and the verdict
+ * move it carries, is pinned in `SchemaRenderer.dataRootUnbound-9308.test.tsx`.
+ *
* ## objectui#5756 — the KNOWN LIMIT below is now FIXED, not pinned as a gap
*
* The 2026-08-22 "no interpolation changes" ruling quoted above was **this
@@ -84,23 +101,41 @@ const Probe = (props: { content?: unknown }) => (
);
/**
- * The data-source ADAPTER — what `SchemaRendererContext` carries and what
- * `${data.total}` resolves against. It has `total` and deliberately has NO
- * `status`: `status` is a ROW field, and the whole card is that the node tier
- * never bound the row over `data`.
+ * The data-source ADAPTER — what `SchemaRendererContext` carries.
+ *
+ * ⭐ Since objectui#9308 it is INERT for every assertion in this file: the
+ * renderer no longer publishes it as the `data` root. It is still injected on
+ * every mount below, and that is the point — the groups that stay silent do so
+ * because the SCOPE answers, never because this object does. Note it carries
+ * `total` while group 2's scope carries `total` too: if the re-aim were ever
+ * reverted, group 2 would go on passing for the wrong reason, which is why
+ * group 2c moves `status` and this object deliberately never gains one.
*/
const ADAPTER = { total: 99 };
-/** Same adapter, plus the key the predicate asks for. Group 2c's other half. */
-const ADAPTER_WITH_STATUS = { total: 99, status: 'draft' };
+/** The host-published `data` group 2c asks for. */
+const DATA_WITH_STATUS = { total: 99, status: 'draft' };
/** …and the polarity that makes group 2c's `false` a verdict, not an absence. */
-const ADAPTER_WITH_OTHER_STATUS = { total: 99, status: 'published' };
+const DATA_WITH_OTHER_STATUS = { total: 99, status: 'published' };
const DRAFT = { id: 'r1', status: 'draft' };
const PUBLISHED = { id: 'r2', status: 'published' };
const cel = (source: string) => ({ dialect: 'cel', source });
-/** The ambient scope app-shell's `ExpressionProvider` really mounts. */
+/**
+ * A HOST scope that publishes its own `data` root through the documented
+ * channel (`PredicateScopeProvider`).
+ *
+ * ⚠️ This is NOT what app-shell's `ExpressionProvider` mounts, and the comment
+ * that used to say so was stale: `buildExpressionScope` returns
+ * `{ current_user, user, ctx, os, features }` and has published no `data` since
+ * objectui#8166. Keeping an (empty) `data` here is deliberate — since
+ * objectui#9308 the renderer binds no `data` of its own, so a host publication
+ * is the ONLY way this file's subject (a `data.*` read the bound `data` cannot
+ * answer) is reachable at all. The other population — no `data` anywhere, the
+ * real app-shell shape — is objectui#5454's leg and is pinned in
+ * `SchemaRenderer.dataRootUnbound-9308.test.tsx` leg 7.
+ */
const APP_SCOPE = {
current_user: { id: 'u1', email_verified: true },
user: { id: 'u1', email_verified: true },
@@ -108,6 +143,9 @@ const APP_SCOPE = {
features: {},
};
+/** The same host scope, publishing a `data` root that DOES answer. */
+const scopeWithData = (data: Record) => ({ ...APP_SCOPE, data });
+
function mount(
schema: Record,
record?: Record,
@@ -280,24 +318,24 @@ describe('#5687 group 1 — the card\'s reproduction shape is reported', () => {
describe('#5687 group 2 — a genuine adapter read stays SILENT', () => {
it('2a: `${data.total}` in a props bag still interpolates, and says nothing', () => {
- // The docblock's pinned binding, restated: `data` is the adapter, and this
- // card does not touch it. A props-bag interpolation never reaches the
- // visibility chain at all — asserted here so the claim is measured, not
- // inferred from where the call site happens to sit.
+ // A props-bag interpolation never reaches the visibility chain at all —
+ // asserted here so the claim is measured, not inferred from where the call
+ // site happens to sit. objectui#9308: the object it interpolates from is
+ // the host's scope `data`, not the adapter.
const warn = spyWarn();
- mount({ properties: { content: '${data.total}' } }, DRAFT);
+ mount({ properties: { content: '${data.total}' } }, DRAFT, ADAPTER, scopeWithData({ total: 99 }));
expect(screen.getByTestId('probe')).toHaveAttribute('data-content', '99');
expect(reports(warn)).toHaveLength(0);
});
- it('2b: a `data.*` VISIBILITY gate the adapter answers is silent, on both polarities', () => {
+ it('2b: a `data.*` VISIBILITY gate the host scope answers is silent, on both polarities', () => {
// The harder half of 2a: this one DOES reach the reporter's call site, and
- // is silent because the adapter answers the read.
+ // is silent because the bound `data` answers the read.
const warn = spyWarn();
- mount({ properties: { visible: 'data.total > 0' } }, DRAFT);
+ mount({ properties: { visible: 'data.total > 0' } }, DRAFT, ADAPTER, scopeWithData({ total: 99 }));
expect(shown()).toBe(true);
cleanup();
- mount({ properties: { visible: 'data.total > 100' } }, DRAFT);
+ mount({ properties: { visible: 'data.total > 100' } }, DRAFT, ADAPTER, scopeWithData({ total: 99 }));
// A correctly-hiding gate. This is also the case that DISQUALIFIES a
// "the predicate is constant-false" trigger: the verdict here is `false`,
// exactly as in group 1, and it must stay silent.
@@ -305,13 +343,14 @@ describe('#5687 group 2 — a genuine adapter read stays SILENT', () => {
expect(reports(warn)).toHaveLength(0);
});
- it('2c: SAME predicate text, SAME `false` verdict — silent once the adapter has the key', () => {
- // The control that picks the discriminator. Only the adapter moves.
+ it('2c: SAME predicate text, SAME `false` verdict — silent once the bound `data` has the key', () => {
+ // The control that picks the discriminator. Only the bound `data` moves;
+ // the adapter is the same object in both halves and in group 1.
const warn = spyWarn();
- mount({ properties: { visible: "data.status == 'draft'" } }, DRAFT, ADAPTER_WITH_STATUS);
+ mount({ properties: { visible: "data.status == 'draft'" } }, DRAFT, ADAPTER, scopeWithData(DATA_WITH_STATUS));
expect(shown()).toBe(true);
cleanup();
- mount({ properties: { visible: "data.status == 'draft'" } }, DRAFT, ADAPTER_WITH_OTHER_STATUS);
+ mount({ properties: { visible: "data.status == 'draft'" } }, DRAFT, ADAPTER, scopeWithData(DATA_WITH_OTHER_STATUS));
expect(shown()).toBe(false); // a real verdict, from a real read
expect(reports(warn)).toHaveLength(0);
});
@@ -423,8 +462,12 @@ describe('#5687 group 4 — production is untouched', () => {
import('../hooks/useExpression'),
]);
core.ComponentRegistry.register(NAME, Probe as never, { namespace: 'element', skipFallback: true } as never);
- const mountProd = (schema: Record, record: Record) => render(
-
+ const mountProd = (
+ schema: Record,
+ record: Record,
+ scope: Record = APP_SCOPE,
+ ) => render(
+
@@ -437,8 +480,9 @@ describe('#5687 group 4 — production is untouched', () => {
mountProd({ properties: { visible: "data.status == 'draft'" } }, DRAFT);
expect(shown()).toBe(false);
cleanup();
- // The genuine adapter read: same interpolation.
- mountProd({ properties: { content: '${data.total}' } }, DRAFT);
+ // The genuine answered read: same interpolation (objectui#9308 — from the
+ // host-published `data`, not from the adapter).
+ mountProd({ properties: { content: '${data.total}' } }, DRAFT, scopeWithData({ total: 99 }));
expect(screen.getByTestId('probe')).toHaveAttribute('data-content', '99');
cleanup();
// The canonical spelling: same two verdicts.
@@ -493,15 +537,15 @@ describe('#5756 group 5 — the design points this card left open', () => {
expect(reports(warn)).toHaveLength(0);
});
- it('5b: a GENUINE adapter read spelled as a `properties` TEMPLATE stays silent, on both polarities', () => {
+ it('5b: a GENUINE answered read spelled as a `properties` TEMPLATE stays silent, on both polarities', () => {
// The template-dialect sibling of group 2b — extending that silence to
// the spelling this card's diagnostic newly reaches, so the new call site
// does not turn every properties-authored template gate into noise.
const warn = spyWarn();
- mount({ properties: { visible: '${data.total > 0}' } }, DRAFT);
+ mount({ properties: { visible: '${data.total > 0}' } }, DRAFT, ADAPTER, scopeWithData({ total: 99 }));
expect(shown()).toBe(true); // 99 > 0
cleanup();
- mount({ properties: { visible: '${data.total > 100}' } }, DRAFT);
+ mount({ properties: { visible: '${data.total > 100}' } }, DRAFT, ADAPTER, scopeWithData({ total: 99 }));
expect(shown()).toBe(false); // 99 > 100 is a REAL verdict, not an absence
expect(reports(warn)).toHaveLength(0);
});
diff --git a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx
index 8fe4e76ba0..2612361e43 100644
--- a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx
@@ -57,6 +57,7 @@ import { ComponentRegistry, ExpressionEvaluator } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
import type { DataSource } from '@object-ui/types';
+import { PredicateScopeProvider } from '../hooks/useExpression';
/**
* NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context
@@ -93,9 +94,11 @@ const FAILS = { dialect: 'cel', source: 'has(data.status) && data.status == "pub
function mount(schema: unknown) {
return render(
-
+
+
- ,
+
+ ,
);
}
diff --git a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx
index fa67d9dec6..ef942d45ba 100644
--- a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx
@@ -65,6 +65,7 @@ import { ComponentRegistry } from '@object-ui/core';
import type { BaseSchema, DataSource, ExpressionWire } from '@object-ui/types';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
/**
* NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context
@@ -94,9 +95,11 @@ const DATA = { status: 'draft', published: false };
/** The DECLARED path -- `BaseSchema`, nothing wider, no cast. */
function mount(schema: BaseSchema) {
return render(
-
+
+
- ,
+
+ ,
);
}
diff --git a/packages/react/src/__tests__/SchemaRenderer.propertiesExpressions.test.tsx b/packages/react/src/__tests__/SchemaRenderer.propertiesExpressions.test.tsx
index 3a070365e8..a8dc7025c4 100644
--- a/packages/react/src/__tests__/SchemaRenderer.propertiesExpressions.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.propertiesExpressions.test.tsx
@@ -28,6 +28,7 @@ import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
const DATA = { total: 99, label: 'Widgets' };
@@ -65,9 +66,11 @@ const PlainProbe = (props: any) => (
const renderWithData = (schema: any) =>
render(
-
+
+
+
);
describe('SchemaRenderer — expression evaluation of `properties` (objectui#4799)', () => {
diff --git a/packages/react/src/__tests__/SchemaRenderer.propsBagDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.propsBagDiagnostic.test.tsx
index 13b0428939..230fe1aeec 100644
--- a/packages/react/src/__tests__/SchemaRenderer.propsBagDiagnostic.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.propsBagDiagnostic.test.tsx
@@ -52,6 +52,7 @@ import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
import {
DROPPED_PROPS_BAG_PREFIX,
collectDroppedPropsKeys,
@@ -65,9 +66,11 @@ const DATA = { customers: ['ada', 'grace'] };
const renderWithData = (schema: unknown) =>
render(
-
+
+
- ,
+
+ ,
);
/**
@@ -467,9 +470,11 @@ describe('objectui#6708 — the SchemaRenderer-tier diagnostic', () => {
const node = { type: 'test-6708:probe', id: 'rerendered', props: { data: 1 } };
const { rerender } = renderWithData(node);
rerender(
-
+
+
- ,
+
+ ,
);
expect(warnings()).toHaveLength(1);
});
diff --git a/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx
index fc757a1947..afedc88400 100644
--- a/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx
@@ -25,6 +25,7 @@ import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import { SchemaRendererContext } from '../context/SchemaRendererContext';
+import { PredicateScopeProvider } from '../hooks/useExpression';
import {
collectUnevaluatedExpressions,
findExpressionSources,
@@ -53,9 +54,11 @@ const SpreadProbe = (props: any) => (
const renderWithData = (schema: any) =>
render(
-
+
+
+
);
/** Only the diagnostic's own lines — never the whole console.error traffic. */
@@ -115,9 +118,11 @@ describe('SchemaRenderer — unevaluated `${…}` diagnostic (objectui#4795)', (
const schema = { type: 'test:probe-4795', value: '${data.n}' };
const { rerender } = renderWithData(schema);
rerender(
+
+
);
expect(shouts(spy)).toHaveLength(1);
});
diff --git a/packages/react/src/__tests__/SchemaRenderer.visibleWhenRecordBinding.test.tsx b/packages/react/src/__tests__/SchemaRenderer.visibleWhenRecordBinding.test.tsx
index 84e6371497..9882defbac 100644
--- a/packages/react/src/__tests__/SchemaRenderer.visibleWhenRecordBinding.test.tsx
+++ b/packages/react/src/__tests__/SchemaRenderer.visibleWhenRecordBinding.test.tsx
@@ -79,7 +79,12 @@ const DONE = { id: 'r1', status: 'done' };
const cel = (source: string) => ({ dialect: 'cel', source });
-/** The ambient scope app-shell's `ExpressionProvider` really mounts. */
+/**
+ * A host scope. ⚠️ NOT what app-shell's `ExpressionProvider` mounts — the
+ * comment that said so was stale: `buildExpressionScope` has published no
+ * `data` since objectui#8166, and since objectui#9308 the renderer publishes
+ * none either. The empty `data` here is a deliberate HOST publication.
+ */
const APP_SCOPE = {
current_user: { id: 'u1', email_verified: true },
user: { id: 'u1', email_verified: true },
@@ -165,11 +170,15 @@ describe('#5454 leg 1 — node-level `visibleWhen` binds `record`', () => {
expect(shown()).toBe(true);
});
- it('does NOT overwrite `data` with the row — `${data.*}` still reads the connector adapter', () => {
+ it('does NOT overwrite `data` with the row — a host-published `data` survives', () => {
// The reverse-verification of the narrowest choice in the fix. Binding the
// row over `data` (which `containers.tsx` does on its own surface) would
// silently re-point every `${data.*}` interpolation in a props bag.
- mount({ properties: { content: '${data.total}' } }, DONE);
+ //
+ // objectui#9308 moved WHICH `data` has to survive: the renderer no longer
+ // publishes the adapter under that name, so the one at stake is the one a
+ // host published through the scope channel. `ADAPTER` is inert here.
+ mount({ properties: { content: '${data.total}' } }, DONE, { ...APP_SCOPE, data: { total: 99 } });
expect(screen.getByTestId('probe')).toHaveAttribute('data-content', '99');
});
diff --git a/packages/react/src/context/SchemaRendererContext.tsx b/packages/react/src/context/SchemaRendererContext.tsx
index 9c2e3357f7..06985ddd51 100644
--- a/packages/react/src/context/SchemaRendererContext.tsx
+++ b/packages/react/src/context/SchemaRendererContext.tsx
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { DebugFlags } from '@object-ui/core';
import type { DataSource } from '@object-ui/types';
+import { usePredicateScope } from '../hooks/useExpression.js';
/**
* Host-provided fetch used for `provider: 'api'` view data sources so custom
@@ -81,26 +82,43 @@ export const useSchemaContext = () => {
return context;
};
+/**
+ * Resolve a `bind` path against the ambient predicate scope.
+ *
+ * ## What it reads, and what it deliberately does not (objectui#9308)
+ *
+ * The scope a host publishes through `PredicateScopeProvider` — the same
+ * channel `useCondition` / `useExpression` merge under a locally-passed
+ * context, and the one app-shell's `ExpressionProvider` already feeds. It is
+ * NOT `SchemaRendererContext.dataSource`.
+ *
+ * `dataSource` is the host's injected ADAPTER, declared as the published
+ * `DataSource` contract (objectui#7912). An adapter has no `users` member, no
+ * `value` member, no member a `bind` path names — so walking it resolved
+ * `undefined` for every conformant host, and the nine production readers of
+ * this hook have all been running their fallback (`boundData || schema.items`,
+ * `|| schema.nodes`, or a fall-through to their own fetch chain) ever since.
+ * The only hosts it answered were ones injecting a data bag through a key the
+ * contract says is an adapter, which has been a compile error since
+ * objectui#7912.
+ *
+ * Maintainer ruling 2026-09-13 (option B) points it at the channel that can
+ * actually answer it. No published key is added: the provider, the hook and
+ * the host wiring all already exist.
+ *
+ * Returns `undefined` for an absent or empty path, and for any path the scope
+ * does not carry — the readers' fallbacks are what run then, exactly as
+ * before.
+ */
export const useDataScope = (path?: string) => {
- const context = useContext(SchemaRendererContext);
- const dataSource = context?.dataSource;
+ const scope = usePredicateScope();
if (!path) return undefined;
- if (!dataSource) return undefined;
+ if (!scope) return undefined;
// Simple path resolution for now. In real app might be more complex.
//
- // The accumulator is `any` BY DECLARATION, not by inheritance: this walk
- // addresses arbitrary member names on the injected value, and `DataSource`
- // declares none of them, so indexing it by a path segment is an error the
- // moment the seam above stops being `any` (objectui#7912). The hook's
- // published return type is unchanged — it was `any` before this annotation
- // and it is `any` after it, so no reader of `useDataScope` moves.
- //
- // ⚠️ What the type now makes visible: against a REAL adapter every path
- // resolves to `undefined`, because an adapter has no `users`/`value` member
- // to walk. Hosts that get data out of this hook are injecting a data bag
- // through a key the contract says is an adapter. Whether that second meaning
- // becomes real or is retired is NOT decided here — same shape, and the same
- // deliberate non-decision, as the `ctx?.formValues ?? ctx?.data` tail on
- // objectui#7206.
- return path.split('.').reduce((acc, part) => acc && acc[part], dataSource);
+ // The accumulator is `any` BY DECLARATION: this walk addresses arbitrary
+ // member names on a host-published bag, and `Record` declares
+ // none of them beyond the first segment. The hook's published return type is
+ // unchanged — `any` before and after — so no reader of `useDataScope` moves.
+ return path.split('.').reduce((acc, part) => acc && acc[part], scope);
}
diff --git a/packages/react/src/context/__tests__/useDataScope.test.tsx b/packages/react/src/context/__tests__/useDataScope.test.tsx
index acd92221a7..5abc8d64de 100644
--- a/packages/react/src/context/__tests__/useDataScope.test.tsx
+++ b/packages/react/src/context/__tests__/useDataScope.test.tsx
@@ -1,103 +1,132 @@
/**
- * Tests for useDataScope hook — verifies correct scoping behavior.
+ * Tests for the `useDataScope` hook — what it resolves a `bind` path against.
+ *
+ * ## objectui#9308 — the object it walks moved, the contract did not
+ *
+ * Maintainer ruling 2026-09-13 (option B, half b2): the hook reads the ambient
+ * predicate scope a host publishes through `PredicateScopeProvider` — the
+ * channel app-shell's `ExpressionProvider` already feeds — and no longer walks
+ * `SchemaRendererContext.dataSource`.
+ *
+ * `dataSource` is the host's injected ADAPTER, declared as the published
+ * `DataSource` contract (objectui#7912). An adapter answers none of the member
+ * names a `bind` path spells, so the walk returned `undefined` for every
+ * conformant host and the nine production readers have been running their
+ * fallbacks throughout. The only hosts it answered were ones injecting a data
+ * bag through the adapter key, which has been a compile error since #7912.
+ *
+ * Every case below is the SAME case it was, with the bag moved from the
+ * adapter seam to the scope channel — plus two controls that did not exist,
+ * which are the ones that would catch a revert: the adapter seam must now
+ * answer NOTHING (`the adapter seam is not the data scope`), and a bag on the
+ * scope must answer even while an adapter is injected beside it.
*/
import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import React from 'react';
import { SchemaRendererProvider, useDataScope } from '../SchemaRendererContext';
+import { PredicateScopeProvider } from '../../hooks/useExpression';
import type { DataSource } from '@object-ui/types';
+/** Mount the hook under a host-published scope. */
+const inScope = (scope: Record) =>
+ ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
/**
- * NOTE (objectui#7912): `SchemaRendererProvider.dataSource` — and the context
- * it feeds — declare the published `DataSource` adapter contract. The values
- * this file injects are deliberately NOT adapters —
- * `useDataScope` walks the injected value BY PATH, so a probe for it injects a
- * bag rather than an adapter.
- * Each injection therefore crosses the contract with an explicit
- * `as unknown as DataSource`. Every injected value is byte-for-byte what it
- * was before: this marks the crossing, it changes no assertion.
+ * Mount the hook under the ADAPTER seam only.
+ *
+ * The values injected here are deliberately NOT adapters, so each injection
+ * crosses the published contract with an explicit `as unknown as DataSource`
+ * (objectui#7912). That crossing is the point: it is the shape of the host
+ * this card breaks.
*/
+const underAdapter = (dataSource: unknown) =>
+ ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
describe('useDataScope', () => {
it('returns undefined when no path is provided', () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
- );
-
- const { result } = renderHook(() => useDataScope(undefined), { wrapper });
-
+ const { result } = renderHook(() => useDataScope(undefined), {
+ wrapper: inScope({ users: [1, 2, 3] }),
+ });
expect(result.current).toBeUndefined();
});
it('returns undefined when path is empty string', () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
- );
-
- const { result } = renderHook(() => useDataScope(''), { wrapper });
-
+ const { result } = renderHook(() => useDataScope(''), {
+ wrapper: inScope({ users: [1, 2, 3] }),
+ });
expect(result.current).toBeUndefined();
});
it('returns scoped data when a valid path is given', () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
- );
-
- const { result } = renderHook(() => useDataScope('users'), { wrapper });
-
+ const { result } = renderHook(() => useDataScope('users'), {
+ wrapper: inScope({ users: [{ name: 'Alice' }] }),
+ });
expect(result.current).toEqual([{ name: 'Alice' }]);
});
it('resolves nested paths', () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
- );
-
- const { result } = renderHook(() => useDataScope('app.settings.theme'), { wrapper });
-
+ const { result } = renderHook(() => useDataScope('app.settings.theme'), {
+ wrapper: inScope({ app: { settings: { theme: 'dark' } } }),
+ });
expect(result.current).toBe('dark');
});
it('returns undefined for non-existent path', () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
- );
-
- const { result } = renderHook(() => useDataScope('nonexistent'), { wrapper });
-
+ const { result } = renderHook(() => useDataScope('nonexistent'), {
+ wrapper: inScope({ users: [] }),
+ });
expect(result.current).toBeUndefined();
});
- it('returns undefined when no SchemaRendererProvider is present', () => {
+ it('returns undefined when no provider is present at all', () => {
+ // `usePredicateScope` defaults to `{}`, so this is a path miss rather than
+ // a throw — the same answer the hook gave outside a
+ // `SchemaRendererProvider` before this card.
const { result } = renderHook(() => useDataScope('users'));
+ expect(result.current).toBeUndefined();
+ });
+ it('does not return the scope object itself when no path is given', () => {
+ const { result } = renderHook(() => useDataScope(undefined), {
+ wrapper: inScope({ users: [1, 2, 3] }),
+ });
+ // Returning the whole bag would make every `bind`-less node "bound", which
+ // is what prevented ObjectChart from fetching when this was last got wrong.
expect(result.current).toBeUndefined();
});
+});
- it('does not return the adapter/service object when no path is given', () => {
- // Simulate the real scenario: dataSource is a service adapter with methods
+describe('useDataScope — objectui#9308 controls: the adapter seam is not the data scope', () => {
+ it('a bag injected as `dataSource` answers NOTHING', () => {
+ const { result } = renderHook(() => useDataScope('users'), {
+ wrapper: underAdapter({ users: [{ name: 'Alice' }] }),
+ });
+ expect(result.current).toBeUndefined();
+ });
+
+ it('a real adapter answers nothing either — and never hands its own members back', () => {
const adapter = { find: () => {}, create: () => {}, update: () => {} };
+ expect(renderHook(() => useDataScope('find'), { wrapper: underAdapter(adapter) }).result.current)
+ .toBeUndefined();
+ expect(renderHook(() => useDataScope(undefined), { wrapper: underAdapter(adapter) }).result.current)
+ .toBeUndefined();
+ });
+
+ it('the scope answers even with an adapter mounted beside it', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
+
+
+ {children}
+
+
);
-
- const { result } = renderHook(() => useDataScope(undefined), { wrapper });
-
- // Should NOT return the adapter — that would prevent ObjectChart from fetching
- expect(result.current).toBeUndefined();
+ const { result } = renderHook(() => useDataScope('users'), { wrapper });
+ expect(result.current).toEqual([{ name: 'Alice' }]);
});
});
diff --git a/packages/react/src/utils/visibilityDiagnostic.ts b/packages/react/src/utils/visibilityDiagnostic.ts
index b820974115..46800e9ebc 100644
--- a/packages/react/src/utils/visibilityDiagnostic.ts
+++ b/packages/react/src/utils/visibilityDiagnostic.ts
@@ -433,7 +433,7 @@ export function __resetVisibilityPredicateWarnings(): void {
* that is not there.
*/
export const ADAPTER_ONLY_DATA_PREDICATE_PREFIX =
- '[ObjectUI] A visibility predicate resolved `data.*` against the data-source adapter';
+ '[ObjectUI] A visibility predicate read `data.*` that the bound `data` does not answer';
/**
* The `disabled` / `disabledOn` sibling of the prefix above (objectui#6504,
@@ -445,7 +445,7 @@ export const ADAPTER_ONLY_DATA_PREDICATE_PREFIX =
* both read.
*/
export const ADAPTER_ONLY_ENABLEMENT_PREDICATE_PREFIX =
- '[ObjectUI] An enablement predicate resolved `data.*` against the data-source adapter';
+ '[ObjectUI] An enablement predicate read `data.*` that the bound `data` does not answer';
/**
* Which gates the objectui#5687 constant-predicate diagnostic is wired to
@@ -471,7 +471,7 @@ export type AdapterOnlyPredicateGateKind = Extract