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