From 7ba9d25716d6c5211f9c89a4523d5591781aa09e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 07:02:22 +0000 Subject: [PATCH 1/4] feat(react)!: unbind the data-source adapter from the expression scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#9308, maintainer ruling 2026-09-13, option B. b1 — `SchemaRenderer` no longer writes `data: dataSource` into the evaluator scope. Against a conformant `DataSource` adapter every `data.*` path already resolved `undefined`, so this tier published a root it could not answer. Same principle `ExpressionProvider` states and objectui#8155 / objectui#8166 already applied one tier up. b2 — `useDataScope` resolves `bind` against `usePredicateScope()`, the ambient scope a host publishes through `PredicateScopeProvider`, instead of walking the injected adapter. No published key is added; the channel already existed. Also re-aims `reportAdapterOnlyDataPredicate` at the `data` the evaluator really bound, and moves its copy off the "data is the adapter" claim. Left on the adapter it would have gone silent for exactly the hosts this change breaks and loud for the ones whose scope-channel `data` answers. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- packages/react/src/SchemaRenderer.tsx | 57 ++++- ...derer.aliasPrecedenceCrossChannel.test.tsx | 5 +- .../SchemaRenderer.bindableTextKeys.test.tsx | 7 +- ...hemaRenderer.dataRootUnbound-9308.test.tsx | 202 ++++++++++++++++++ ...enderer.degeneratePropertiesHoist.test.tsx | 7 +- ...SchemaRenderer.degeneratePropsBag.test.tsx | 7 +- ...hemaRenderer.disabledDeclaredGate.test.tsx | 7 +- ...derer.disabledGateFaultDiagnostic.test.tsx | 36 +++- ...derer.enablementEnvelopeConfigBag.test.tsx | 7 +- .../SchemaRenderer.expressions.test.tsx | 19 ++ ...SchemaRenderer.hiddenDeclaredGate.test.tsx | 13 +- ...emaRenderer.nodeGateDataPredicate.test.tsx | 100 ++++++--- ...nderer.predicateEnvelopeConfigBag.test.tsx | 7 +- ...enderer.predicateEnvelopeDeclared.test.tsx | 7 +- ...emaRenderer.propertiesExpressions.test.tsx | 5 +- ...SchemaRenderer.propsBagDiagnostic.test.tsx | 13 +- ...r.unevaluatedExpressionDiagnostic.test.tsx | 7 +- ...Renderer.visibleWhenRecordBinding.test.tsx | 15 +- .../src/context/SchemaRendererContext.tsx | 54 +++-- .../context/__tests__/useDataScope.test.tsx | 149 +++++++------ .../react/src/utils/visibilityDiagnostic.ts | 46 ++-- 21 files changed, 607 insertions(+), 163 deletions(-) create mode 100644 packages/react/src/__tests__/SchemaRenderer.dataRootUnbound-9308.test.tsx 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 Date: Sun, 13 Sep 2026 07:14:24 +0000 Subject: [PATCH 2/4] docs(react): teach the scope channel, and re-derive the pins that hold it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#9308 + objectui#9346. `Doc Snippet Type Check` was red on `main` with three fences that taught publishing page values through `dataSource` and reading them back under `data.*`. Under the 2026-09-13 ruling that teaching is wrong, so the Data Context passages of `content/docs/guide/schema-rendering.md` and `packages/react/README.md` move to `PredicateScopeProvider`, and the README sentence asserting that `dataSource` "is what the evaluator sees under the name `data`" — which the gate cannot see — goes with them. objectui#8021's pin is RE-DERIVED, not relaxed: leg A still reads the page on every run, leg C's wiring and leg D's expression swap to the new teaching, and a new leg E pins the adapter seam as a non-root. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../9308-data-root-unbound-from-adapter.md | 64 +++++++ content/docs/guide/schema-rendering.md | 51 +++--- .../bindable-text-keys-4795.test.tsx | 8 +- .../data-table-node-data-diagnostic.test.tsx | 6 +- .../disabled-verdict-one-carrier.test.tsx | 8 +- ...chema-rendering-data-context-8021.test.tsx | 161 +++++++++++------- .../skill-guide-data-table-binding.test.tsx | 8 +- .../skill-guide-provider-envelope.test.tsx | 8 +- .../shadowed-renderer-behaviour.test.tsx | 8 +- .../complex/dataTableBindDiagnostic.ts | 18 +- packages/react/README.md | 29 ++-- packages/types/src/base.ts | 9 +- 12 files changed, 265 insertions(+), 113 deletions(-) create mode 100644 .changeset/9308-data-root-unbound-from-adapter.md 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..70baff93cc 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 — `stats`, `current_user`, 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/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/types/src/base.ts b/packages/types/src/base.ts index d82e3183c8..8bf38322b2 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -187,8 +187,15 @@ export interface BaseSchema { * Data-scope path this node draws its rows/value from — the SDUI data-binding * vocabulary, resolved by `useDataScope()` (`@object-ui/react`). * + * The path is resolved against the AMBIENT SCOPE a host publishes through + * `PredicateScopeProvider` — the channel app-shell's `ExpressionProvider` + * already feeds — and ⛔ not against the injected `DataSource` adapter + * (objectui#9308, maintainer ruling 2026-09-13 option B). An adapter answers + * no member a `bind` path names, so the old walk resolved `undefined` for + * every conformant host and each reader below ran its fallback. + * * ```json - * { "type": "list", "bind": "customerNames" } // → dataSource.customerNames + * { "type": "list", "bind": "customerNames" } // → scope.customerNames * { "type": "object-kanban", "bind": "app.settings.users" } * ``` * From 7d1cd93201df883ae0bb45d0ecba78170c3dad63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 07:32:05 +0000 Subject: [PATCH 3/4] docs: name the published roots in the Data Context table the way the fence does Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- content/docs/guide/schema-rendering.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md index 70baff93cc..5c7940074b 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -112,7 +112,7 @@ The scope the evaluator builds is what you published, plus three names the rende | name | what it holds | |---|---| -| every key of `scope` | exactly what you put there — `stats`, `current_user`, whatever the page needs | +| 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` | an alias of whatever you published as `user`; the host's `ExpressionProvider` publishes the signed-in user there | From 4de2534f25e67873d88f16ac5bfe8864395e4919 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 07:40:06 +0000 Subject: [PATCH 4/4] test(plugin-grid,plugin-dashboard): the two `bind` reader pins publish through the scope channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#9308 b2 — `useDataScope` resolves the ambient predicate scope, so a pin that fed the bound rows through `SchemaRendererProvider.dataSource` was measuring a channel the hook no longer reads. Both keep the adapter seam mounted, where it is now inert, and neither assertion moved. Found by narrowing the dependent packages to the tests that can reach b1 or b2 at all: 1987 candidate test files, 7 reachable, 4 failures — all in this pair. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../ObjectDataTable.bindNotForwarded-6575.test.tsx | 12 ++++++++---- .../columnSpellingDiagnosticRender.test.tsx | 11 +++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) 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} , );