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
87 changes: 87 additions & 0 deletions .changeset/7912-schema-renderer-datasource-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
'@object-ui/react': minor
'@object-ui/components': minor
'@object-ui/fields': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-calendar': minor
'@object-ui/plugin-gantt': minor
'@object-ui/plugin-kanban': minor
'@object-ui/plugin-charts': minor
'@object-ui/plugin-dashboard': minor
'@object-ui/plugin-detail': minor
---

**BREAKING** — `SchemaRendererProvider`'s `dataSource` prop, and the context
type every `useSchemaContext()` consumer reads back, are the published
`DataSource` contract instead of `any`.

**FROM** `dataSource={anything}` **TO** `dataSource={adapter}` — a `DataSource`
from `@object-ui/types`, or `null` / `undefined` when the host has no adapter
bound.

```ts
// before — compiled, and failed at runtime on the first find()
<SchemaRendererProvider dataSource={'not-an-adapter'}>
// before — compiled, and no reader can do anything with it
<SchemaRendererProvider dataSource={{}}>
// after
<SchemaRendererProvider dataSource={adapter}> // a DataSource
<SchemaRendererProvider dataSource={undefined}> // "I have no adapter"
```

Both sites are typed `DataSource | null | undefined` — the spelling
`useSettledSchema` in this same package already used. The two absences are part
of the contract, not a weakening of it: a Studio preview, a `kind:'react'` page
rendered before the host's adapter connects, and a widget probe driving
`apiFetch` alone all render with nothing bound, and every reader in the tree
already guards for it. What the union refuses is everything that is not an
adapter: a string, an empty object, a plain data bag, a partial adapter missing
a required member.

The measured cost, both halves, because the two `any`s have different blast
radii (measured separately on `origin/main`, whole-repo type-check over the 33
packages that depend on `@object-ui/react`):

- the **context type** — the `any` that reaches every `useSchemaContext()`
reader — reds **7 diagnostics at 7 sites in 4 packages**, all of them
production code or a mocked module factory.
- the **provider prop** — the injection points — reds **52 diagnostics at 27
sites in 11 packages**, all but one of them test doubles.

That ordering is the reverse of the prediction on the card: the context `any`
was expected to be the expensive one because it infects the whole tree, and it
is the cheap one, because every reader in the tree already guarded and none of
them ever reached past `find` / `getObjectSchema`. The prop is the expensive
one, because the injection points are overwhelmingly test doubles that were
never complete adapters. The full accounting is on objectui#7912.

Two runtime behaviours change, both in the "no adapter" direction and both
strictly closer to what the surrounding code already intended:

- `@object-ui/components`' `kind:'react'` page passed an empty object as its
"no adapter yet" stand-in. An empty object is TRUTHY, so it walked past every
`if (!dataSource)` guard written to catch exactly that state and failed later,
at the call. It now passes the absent adapter itself, so the guard fires where
it was meant to. The module-constant identity that stand-in existed for is
preserved: `null` is a primitive, so the provider's memo is unaffected.
- `@object-ui/plugin-calendar`, `@object-ui/plugin-gantt` and
`@object-ui/plugin-kanban` collapse a `null` adapter from the context to
`undefined` before handing it to their widget, whose prop declares the single
spelling `dataSource?: DataSource`.

Nothing else moves at runtime: no value flowing through this key changes, and
no data path is touched. A TypeScript consumer outside this repo that handed
this prop something other than an adapter now gets a compile error naming the
key (TS2322 / TS2739 / TS2740), which is why the FROM/TO is spelled out above.

Five `as any` reads of this context in `@object-ui/fields` are gone — they were
redundant the moment the seam became honest — and `LookupField`'s local
re-declaration of the imported context as a `Context` of `any`, which laundered
its `dataSource` read while looking typed, is gone with them. Both directions of
the contract are pinned against the real compiler in
`SchemaRendererContext.dataSourceType.pin.test.ts`, and the card's planted
documentation probe (a bare string in `packages/react/README.md`'s provider
example) now fails `pnpm check:doc-snippets`, where it used to exit 0 with zero
diagnostics.

objectui#7912.
14 changes: 13 additions & 1 deletion apps/site/app/components/LiveSplitDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,19 @@ const PRESET_LABELS: Record<(typeof PRESET_IDS)[number], string> = {
'components-complex-table/basic-table': 'Table',
};

const defaultCtx = { dataSource: {} };
/**
* The provider value these demos render under. Every preset in `PRESET_IDS` is
* a STATIC schema — the object-bound examples live in `InteractiveDemo` /
* `SchemaThumbnail`, which inject `galleryDataSource` — so this surface has no
* adapter to hand over and says so with `undefined` (objectui#7912).
*
* It used to say `{}`. An empty object is TRUTHY, so it walked past the
* `if (!dataSource)` guard every reader writes for exactly this state; the seam
* now declares `DataSource | null | undefined`, so the absence is stated rather
* than smuggled. Still a module constant, for the same reason as before: the
* memo below keys on its identity, and `undefined` is render-stable.
*/
const defaultCtx = { dataSource: undefined };

class PreviewErrorBoundary extends Component<
{ children: ReactNode; signal: unknown },
Expand Down
8 changes: 5 additions & 3 deletions content/docs/guide/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const schema: CardSchema = {
function App() {
return (
<div className="min-h-screen bg-background p-8 text-foreground">
<SchemaRendererProvider dataSource={{}}>
<SchemaRendererProvider dataSource={undefined}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>
</div>
Expand All @@ -119,7 +119,9 @@ function App() {
export default App;
```

Importing `@object-ui/components` and `@object-ui/fields` registers their renderers with the shared `ComponentRegistry`. `SchemaRendererProvider` supplies the data scope used by expressions, smart fields, and data-aware plugins.
Importing `@object-ui/components` and `@object-ui/fields` registers their renderers with the shared `ComponentRegistry`. `SchemaRendererProvider` injects the host's data adapter, and everything below it — expressions, smart fields, data-aware plugins — reads it back from there.

This app renders inline `data`, so it has no adapter to inject and says so with `undefined`. That is a real state of the contract, not a placeholder: `dataSource` is typed `DataSource | null | undefined` (`@object-ui/types`), so a host either hands over an adapter or states that it has none. It used to be typed `any` and this example passed an empty object, which no renderer can do anything with (objectui#7912).

## Step 5: Run the App

Expand All @@ -134,7 +136,7 @@ Open [http://localhost:5173](http://localhost:5173). You should see a card and d
1. **Schema** - the UI was described as JSON with `type`, visual props, and nested `body`.
2. **Registry** - importing the component packages registered renderers for `card` and `data-table`.
3. **Renderer** - `SchemaRenderer` resolved each `type` and rendered React components.
4. **Provider** - `SchemaRendererProvider` made a data scope available for expressions and plugins.
4. **Provider** - `SchemaRendererProvider` is where a host injects its `DataSource`; this app has none, so it passes `undefined`.

## Next Steps

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,25 @@ import { SchemaRenderer, SchemaRendererProvider } 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';
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 —
* they are the `data` ROOT of the expression scope — the renderer binds
* `SchemaRendererContext.dataSource` as `data` for every predicate, which is
* the second meaning this one key carries.
* 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.
*/

const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' };

const renderNode = (schema: any) =>
render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRendererProvider dataSource={DATA as unknown as DataSource}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,22 @@ import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// self-import (`scripts/check-package-self-import.mjs`).
import '../renderers';
import {

/**
* 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 —
* they are the `data` ROOT of the expression scope — the renderer binds
* `SchemaRendererContext.dataSource` as `data` for every predicate, which is
* the second meaning this one key carries.
* 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.
*/
DATA_TABLE_BIND_DIAGNOSTIC_PREFIX,
DATA_TABLE_DATA_DIAGNOSTIC_PREFIX,
} from '../renderers/complex/dataTableBindDiagnostic';
import type { DataSource } from '@object-ui/types';

/** Identical in every leg, so the only variable is where `data` was written. */
const COLUMNS = [
Expand Down Expand Up @@ -101,7 +114,7 @@ function warningsOn(prefix: string): string[] {

function tree(schema: unknown) {
return (
<SchemaRendererProvider dataSource={SCOPE}>
<SchemaRendererProvider dataSource={SCOPE as unknown as DataSource}>
<SchemaRenderer schema={schema as never} />
</SchemaRendererProvider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import '../renderers';
import { SchemaRenderer, SchemaRendererProvider } 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
Expand Down Expand Up @@ -108,7 +122,7 @@ function renderWithDataProp(content: string): string {
function renderWithProvider(content: string): string {
return (
render(
<SchemaRendererProvider dataSource={SCOPE}>
<SchemaRendererProvider dataSource={SCOPE as unknown as DataSource}>
<SchemaRenderer schema={textNode(content)} />
</SchemaRendererProvider>,
).container.textContent ?? ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// package self-import (`scripts/check-package-self-import.mjs`).
import '../renderers';
import { DATA_TABLE_BIND_DIAGNOSTIC_PREFIX } from '../renderers/complex/dataTableBindDiagnostic';
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 —
* they are the `data` ROOT of the expression scope — the renderer binds
* `SchemaRendererContext.dataSource` as `data` for every predicate, which is
* the second meaning this one key carries.
* 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.
*/

const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '../../../..');
Expand Down Expand Up @@ -152,7 +165,7 @@ function bindWarnings(): string[] {

function renderNode(schema: unknown, dataSource: unknown) {
return render(
<SchemaRendererProvider dataSource={dataSource}>
<SchemaRendererProvider dataSource={dataSource as unknown as DataSource}>
<SchemaRenderer schema={schema as never} />
</SchemaRendererProvider>,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
// specifier: this file lives inside `@object-ui/components`
// (`scripts/check-package-self-import.mjs`).
import '../renderers';
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 —
* they are the `data` ROOT of the expression scope — the renderer binds
* `SchemaRendererContext.dataSource` as `data` for every predicate, which is
* the second meaning this one key carries.
* 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.
*/

const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '../../../..');
Expand All @@ -90,7 +103,7 @@ const EMPTY_STATE = 'No results foundTry adjusting your filters or search query.

function renderNode(schema: unknown) {
return render(
<SchemaRendererProvider dataSource={PROVIDER}>
<SchemaRendererProvider dataSource={PROVIDER as unknown as DataSource}>
<SchemaRenderer schema={schema as never} />
</SchemaRendererProvider>,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ import { SchemaRenderer, SchemaRendererProvider } 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';
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 —
* they are the `data` ROOT of the expression scope — the renderer binds
* `SchemaRendererContext.dataSource` as `data` for every predicate, which is
* the second meaning this one key carries.
* 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.
*/

const PEOPLE = [
{ name: 'Ada', role: 'Engineer' },
Expand Down Expand Up @@ -59,7 +72,7 @@ const DATA_SOURCE = {

const renderBound = (schema: Record<string, unknown>) =>
render(
<SchemaRendererProvider dataSource={DATA_SOURCE}>
<SchemaRendererProvider dataSource={DATA_SOURCE as unknown as DataSource}>
<SchemaRenderer schema={schema as never} />
</SchemaRendererProvider>,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ import { SchemaRenderer, SchemaRendererProvider, PredicateScopeProvider } from '
// the import phase, not under a hook timeout.
import '../action-button';
import '../action-icon';
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 —
* they are the `data` ROOT of the expression scope — the renderer binds
* `SchemaRendererContext.dataSource` as `data` for every predicate, which is
* the second meaning this one key carries.
* 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.
*/

const DATA = { status: 'draft' };

Expand All @@ -67,7 +80,7 @@ const FAILS = { dialect: 'cel', source: 'has(data.status) && data.status == "pub

function mountAction(type: 'action:button' | 'action:icon', properties: Record<string, unknown>) {
return render(
<SchemaRendererProvider dataSource={DATA}>
<SchemaRendererProvider dataSource={DATA as unknown as DataSource}>
<PredicateScopeProvider scope={{ data: DATA }}>
<SchemaRenderer
schema={
Expand Down
29 changes: 20 additions & 9 deletions packages/components/src/renderers/layout/react-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,27 @@ function buildComponentScope(dataSource: unknown): Record<string, React.Componen
}

/**
* Stand-in for "no adapter yet" — the window before the host's AdapterProvider
* finishes connecting, and any surface that renders a react page without one.
* "No adapter yet" — the window before the host's AdapterProvider finishes
* connecting, and any surface that renders a react page without one — is now
* expressed by handing the seam the absent adapter ITSELF (objectui#7912).
*
* A module constant, not an inline `?? {}`: this is a context value, and
* SchemaRendererProvider memoises on its identity. A fresh object per render
* would break that memo for every block inside the page, re-cloning each
* block's schema and re-running its expressions on every render of the page —
* the same defect the SchemaRenderer fallback had (objectui#2954).
* This replaced a module-level stand-in constant holding an empty object. That
* constant existed for a real reason and the reason still holds:
* `SchemaRendererProvider` memoises its context value on the identity of what
* it is handed, so a fresh empty object per render defeated that memo for every
* block inside the page — re-cloning each block's schema and re-running its
* expressions on every render (objectui#2954). `useAdapter()` returns
* `ObjectStackAdapter | null`, and both arms are render-stable: the adapter is
* the host's own context value, and `null` is a primitive. So the memo keeps
* what it had.
*
* What is gained is that an empty object is TRUTHY: every reader downstream
* asks `if (!dataSource)` before it reaches for `find`, so it walked past the
* one guard written to catch "no adapter" and failed later, at the call. The
* seam declares `DataSource | null | undefined`, so the absence is now stated
* in the type instead of being smuggled through `any` as a value that is not
* an adapter.
*/
const NO_DATA_SOURCE = {};

function CapabilityDisabledNotice(): React.ReactElement {
return (
Expand Down Expand Up @@ -202,7 +213,7 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => {

const { ReactRunner } = runtime;
return (
<SchemaRendererProvider dataSource={adapter ?? NO_DATA_SOURCE}>
<SchemaRendererProvider dataSource={adapter}>
<ReactRunner
code={source}
scope={scope}
Expand Down
4 changes: 3 additions & 1 deletion packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ async function fetchRefObjectSchema(dataSource: any, referenceTo: string): Promi
*/
function useRefObjectSchema(referenceTo: string | undefined): any {
const ctx = React.useContext(_SchemaRendererContext);
const dataSource = ctx?.dataSource as any;
// No cast: the context declares `DataSource | null | undefined` since
// objectui#7912, and this read has no second channel to merge with.
const dataSource = ctx?.dataSource;
const [, force] = React.useState(0);
const canFetch =
!!referenceTo && !!dataSource && typeof dataSource.getObjectSchema === 'function';
Expand Down
Loading
Loading