Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/9308-data-root-unbound-from-adapter.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 31 additions & 20 deletions content/docs/guide/schema-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ interface BaseSchema {
"visibleOn": "${user.role === 'admin'}",
"body": {
"type": "text",
"content": "Total Users: ${data.stats.totalUsers}"
"content": "Total Users: ${stats.totalUsers}"
}
}
```
Expand All @@ -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 (
<SchemaRendererProvider dataSource={dataSource}>
<PredicateScopeProvider scope={scope}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>
</PredicateScopeProvider>
)
}
```

The scope the evaluator builds holds four names, and nothing else:
An app built on `@object-ui/app-shell` does not mount this provider itself: the shell's
`ExpressionProvider` already feeds the same channel with `user` (the signed-in user, also
readable as `current_user`) and `features`.

The scope the evaluator builds is what you published, plus three names the renderer supplies:

| name | what it holds |
|---|---|
| `data` | the `dataSource` the provider above published — everything you passed in |
| every key of `scope` | exactly what you put there — `user`, `stats`, whatever the page needs |
| `page` | page-local variables, for predicates that gate on another component's state |
| `record` | the row a record surface is bound to, when there is one |
| `current_user` (aliased to `user`) | the signed-in user, published by the host's `ExpressionProvider` — not by anything on this page |
| `current_user` | an alias of whatever you published as `user`; the host's `ExpressionProvider` publishes the signed-in user there |

A name outside that set resolves to nothing, and an unresolvable template is not an error:
the evaluator hands back its own source text, so the characters you typed are what the reader
sees.

> **`dataSource` is not an expression root.** `SchemaRendererProvider`'s `dataSource` carries
> the host's `DataSource` *adapter* — the object renderers call `find()` on. The renderer used
> to publish that adapter under the name `data`; an adapter answers no `data.*` path, so the
> root was constant for every conformant host, and objectui#9308 removed it. A `${data.…}`
> expression now reads whatever *you* published under `data`, and nothing if you published
> none. At the runtime layer the row is `record` (ADR-0089).

### Accessing Data in Schemas

Use expression syntax `${}` to reference the scope, and reach your own values through the
`data.` prefix:
Use expression syntax `${}` to reference the scope, by the name you published it under:

```json
{
"type": "text",
"content": "Welcome, ${data.user.name}!"
"content": "Welcome, ${user.name}!"
}
```

Expand Down Expand Up @@ -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.
Expand All @@ -422,18 +433,18 @@ 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,
}

function Dashboard() {
return (
<SchemaRendererProvider dataSource={dataSource}>
<PredicateScopeProvider scope={scope}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>
</PredicateScopeProvider>
)
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -55,9 +55,11 @@ const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA as unknown as DataSource}>
<PredicateScopeProvider scope={{ data: DATA }}>
<SchemaRendererProvider dataSource={DATA as unknown as DataSource}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
</SchemaRendererProvider>
</PredicateScopeProvider>,
);

describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,9 +114,11 @@ function warningsOn(prefix: string): string[] {

function tree(schema: unknown) {
return (
<SchemaRendererProvider dataSource={SCOPE as unknown as DataSource}>
<PredicateScopeProvider scope={{ data: SCOPE }}>
<SchemaRendererProvider dataSource={SCOPE as unknown as DataSource}>
<SchemaRenderer schema={schema as never} />
</SchemaRendererProvider>
</PredicateScopeProvider>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,19 @@
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';

/** `${data.locked}` resolves against this — the `dataSource` on the context. */
function renderNode(schema: Record<string, unknown>, locked: boolean) {
return render(
<SchemaRendererContext.Provider value={{ dataSource: { locked } } as never}>
<PredicateScopeProvider scope={{ data: { locked } }}>
<SchemaRendererContext.Provider value={{ dataSource: { locked } } as never}>
<SchemaRenderer schema={schema as never} />
</SchemaRendererContext.Provider>,
</SchemaRendererContext.Provider>
</PredicateScopeProvider>,
);
}

Expand Down
Loading
Loading