diff --git a/.changeset/7912-schema-renderer-datasource-contract.md b/.changeset/7912-schema-renderer-datasource-contract.md new file mode 100644 index 0000000000..76a60c4b77 --- /dev/null +++ b/.changeset/7912-schema-renderer-datasource-contract.md @@ -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() + +// before — compiled, and no reader can do anything with it + +// after + // a DataSource + // "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. diff --git a/apps/site/app/components/LiveSplitDemo.tsx b/apps/site/app/components/LiveSplitDemo.tsx index ac73a33d12..cfc49b1730 100644 --- a/apps/site/app/components/LiveSplitDemo.tsx +++ b/apps/site/app/components/LiveSplitDemo.tsx @@ -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 }, diff --git a/content/docs/guide/quick-start.md b/content/docs/guide/quick-start.md index fcdfeef315..915f89758b 100644 --- a/content/docs/guide/quick-start.md +++ b/content/docs/guide/quick-start.md @@ -109,7 +109,7 @@ const schema: CardSchema = { function App() { return (
- +
@@ -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 @@ -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 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 456ce07d52..47bc4d6759 100644 --- a/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx +++ b/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx @@ -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( - + , ); 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 6a3aab752e..9f0dadf407 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 @@ -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 = [ @@ -101,7 +114,7 @@ function warningsOn(prefix: string): string[] { function tree(schema: unknown) { return ( - + ); 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 e9557b3dd9..4435fb1c8e 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 @@ -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 @@ -108,7 +122,7 @@ function renderWithDataProp(content: string): string { function renderWithProvider(content: string): string { return ( render( - + , ).container.textContent ?? '' 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 76fdb2814e..ad853f790a 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 @@ -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, '../../../..'); @@ -152,7 +165,7 @@ 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 24a53b6ed4..ff32965d56 100644 --- a/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx +++ b/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx @@ -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, '../../../..'); @@ -90,7 +103,7 @@ 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 46c09cdcb9..da7c526527 100644 --- a/packages/components/src/renderers/__tests__/shadowed-renderer-behaviour.test.tsx +++ b/packages/components/src/renderers/__tests__/shadowed-renderer-behaviour.test.tsx @@ -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' }, @@ -59,7 +72,7 @@ const DATA_SOURCE = { const renderBound = (schema: Record) => render( - + , ); diff --git a/packages/components/src/renderers/action/__tests__/action-enablement-cel-envelope.test.tsx b/packages/components/src/renderers/action/__tests__/action-enablement-cel-envelope.test.tsx index fe5628c443..8a46d19de2 100644 --- a/packages/components/src/renderers/action/__tests__/action-enablement-cel-envelope.test.tsx +++ b/packages/components/src/renderers/action/__tests__/action-enablement-cel-envelope.test.tsx @@ -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' }; @@ -67,7 +80,7 @@ const FAILS = { dialect: 'cel', source: 'has(data.status) && data.status == "pub function mountAction(type: 'action:button' | 'action:icon', properties: Record) { return render( - + = ({ schema }) => { const { ReactRunner } = runtime; return ( - + ) { const { t } = useFieldTranslation(); const ctx = React.useContext(SchemaRendererContext); + // The context leg needs no cast since objectui#7912 typed the seam; the + // `props` leg still does, because `FieldWidgetProps.dataSource` is declared + // `unknown` in this package. const dataSource: DataSource | null = - (props.dataSource as any) ?? (ctx as any)?.dataSource ?? null; + (props.dataSource as DataSource | null | undefined) ?? ctx?.dataSource ?? null; const disabled = props.disabled; const [caps, setCaps] = React.useState(null); diff --git a/packages/fields/src/widgets/FilterConditionField.tsx b/packages/fields/src/widgets/FilterConditionField.tsx index 09e6e5c4e0..ad9b51a859 100644 --- a/packages/fields/src/widgets/FilterConditionField.tsx +++ b/packages/fields/src/widgets/FilterConditionField.tsx @@ -429,7 +429,9 @@ export function FilterConditionField({ }: FieldWidgetComponentProps) { const ctx = React.useContext(SchemaRendererContext); const { t } = useFieldTranslation(); - const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null; + // Cast-free context read (objectui#7912); the local stays `any` for the + // `FieldWidgetProps.dataSource?: unknown` channel it merges with. + const dataSource: any = props.dataSource ?? ctx?.dataSource ?? null; const dependentValues: Record = (props as any).dependentValues ?? {}; const objectName = String(dependentValues.object_name ?? ''); diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index e9d573ce58..11fa2f3eec 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -56,8 +56,17 @@ const LOOKUP_PAGE_SIZE = 50; /** * SchemaRendererContext is created by @object-ui/react. * Using a static import to be compatible with Next.js Turbopack SSR. + * + * ⚠️ This used to re-declare the imported context as `React.Context`. + * That widening was invisible at the read sites — every `ctx?.…` below read as + * `any` while looking perfectly typed — so it also laundered the `dataSource` + * read, which is precisely the consumer face objectui#7912 typed. The import + * is now used AS DECLARED; the one read that needs members the context does + * not declare takes a local widened VIEW of the value (see the + * `resolvedDependentValues` note), so the widening is visible where it happens + * and reaches nothing else. */ -const SchemaRendererContext: React.Context = ImportedSchemaRendererContext; +const SchemaRendererContext = ImportedSchemaRendererContext; /** * A relation whose picker should offer inline "create the referenced record" by @@ -354,6 +363,14 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro // Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none const ctx = useContext(SchemaRendererContext); const contextDataSource = ctx?.dataSource ?? null; + /** A deliberately widened VIEW of the same context value, for the two reads + * below that name members `SchemaRendererContextType` does not declare. It + * exists so that widening cannot reach the `dataSource` read above; the + * reads themselves are unchanged, and objectui#7206 still owns whether that + * channel becomes real or is retired. */ + const untypedCtx = ctx as unknown as + | { formValues?: Record; data?: Record } + | null; const dataSource: DataSource | null = (props.dataSource as DataSource | null | undefined) ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource; @@ -380,8 +397,8 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro * read this note as either outcome. */ const resolvedDependentValues: Record = useMemo(() => { if (dependentValuesProp) return dependentValuesProp; - return (ctx?.formValues ?? ctx?.data ?? {}) as Record; - }, [dependentValuesProp, ctx?.formValues, ctx?.data]); + return (untypedCtx?.formValues ?? untypedCtx?.data ?? {}) as Record; + }, [dependentValuesProp, untypedCtx?.formValues, untypedCtx?.data]); /** True when at least one dependency is missing (empty). The picker is gated * in that state so we never issue an unfiltered query that ignores the diff --git a/packages/fields/src/widgets/ObjectRefField.tsx b/packages/fields/src/widgets/ObjectRefField.tsx index e4c21678f5..d07261113d 100644 --- a/packages/fields/src/widgets/ObjectRefField.tsx +++ b/packages/fields/src/widgets/ObjectRefField.tsx @@ -37,7 +37,11 @@ export function ObjectRefField({ }: FieldWidgetComponentProps) { const ctx = React.useContext(SchemaRendererContext); const { t } = useFieldTranslation(); - const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null; + // The context read is cast-free since objectui#7912 typed the seam. The + // local stays `any` because the OTHER channel, `FieldWidgetProps.dataSource`, + // is declared `unknown` in this package — that is the declaration that + // launders the merged value now, not the renderer context. + const dataSource: any = props.dataSource ?? ctx?.dataSource ?? null; const disabled = props.disabled; const [objects, setObjects] = React.useState(null); diff --git a/packages/fields/src/widgets/RecipientPickerField.tsx b/packages/fields/src/widgets/RecipientPickerField.tsx index e15e6b4a0e..062ac5b308 100644 --- a/packages/fields/src/widgets/RecipientPickerField.tsx +++ b/packages/fields/src/widgets/RecipientPickerField.tsx @@ -59,7 +59,9 @@ export function RecipientPickerField({ }: FieldWidgetComponentProps) { const ctx = React.useContext(SchemaRendererContext); const { t } = useFieldTranslation(); - const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null; + // Cast-free context read (objectui#7912); the local stays `any` for the + // `FieldWidgetProps.dataSource?: unknown` channel it merges with. + const dataSource: any = props.dataSource ?? ctx?.dataSource ?? null; const disabled = props.disabled; const dependentValues: Record = (props as any).dependentValues ?? {}; const recipientType = String(dependentValues.recipient_type ?? ''); diff --git a/packages/plugin-calendar/src/index.tsx b/packages/plugin-calendar/src/index.tsx index ac9230f3f5..0458024195 100644 --- a/packages/plugin-calendar/src/index.tsx +++ b/packages/plugin-calendar/src/index.tsx @@ -247,7 +247,13 @@ export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any // reason an authored string could land on a function-typed prop. ...rest }) => { - const { dataSource } = useSchemaContext() || {}; + // `useSchemaContext()` may hand back a NULL adapter: a host with nothing + // bound spells absence either way, and the seam declares both + // (`DataSource | null | undefined`, objectui#7912). The widget below + // declares the single spelling `dataSource?: DataSource`, so collapse the + // two absences into that one here rather than widening the widget. + const { dataSource: contextDataSource } = useSchemaContext() || {}; + const dataSource = contextDataSource ?? undefined; // The declared host hatches, each kept only at its declared type. Read out of // `rest`; `rest` itself never reaches `ObjectCalendar`. diff --git a/packages/plugin-calendar/src/registration.test.tsx b/packages/plugin-calendar/src/registration.test.tsx index e00765d280..3388e0c592 100644 --- a/packages/plugin-calendar/src/registration.test.tsx +++ b/packages/plugin-calendar/src/registration.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import * as ObjectUIReact from '@object-ui/react'; import { ObjectCalendarRenderer } from './index'; +import type { DataSource } from '@object-ui/types'; // Partial mock — override ONLY what this test controls, keep every other real // export (objectui#3219). @@ -38,7 +39,11 @@ import { ObjectCalendarRenderer } from './index'; vi.mock(import('@object-ui/react'), async (importOriginal) => ({ ...(await importOriginal()), // Only the pieces this test drives: - useSchemaContext: vi.fn(() => ({ dataSource: { type: 'mock-datasource' } })), + // The marker object below is NOT an adapter: the stubbed widget prints + // `dataSource.type`, which is the whole point of this registration probe. + // `useSchemaContext` declares the published `DataSource` contract since + // objectui#7912, so the crossing is explicit; the value is unchanged. + useSchemaContext: vi.fn(() => ({ dataSource: { type: 'mock-datasource' } as unknown as DataSource })), })); // Mock the implementation. Deliberate whole-module replacement of a LOCAL diff --git a/packages/plugin-charts/src/ObjectChart.optionColors.test.tsx b/packages/plugin-charts/src/ObjectChart.optionColors.test.tsx index 1da3a6ba65..8c2ec7214d 100644 --- a/packages/plugin-charts/src/ObjectChart.optionColors.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.optionColors.test.tsx @@ -22,6 +22,7 @@ * right documents, in the right order, and feeds the results to the renderer). */ +import type { DataSource } from '@object-ui/types'; import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, cleanup, waitFor } from '@testing-library/react'; @@ -38,6 +39,18 @@ vi.mock('./ChartRenderer', () => ({ import { ObjectChart } from './ObjectChart'; +/** + * 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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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. + */ + /** * Records every URL and answers only the documents it is given, so an escape * to any other endpoint shows up as a recorded call the assertions reject — @@ -268,7 +281,7 @@ describe('ObjectChart — option-color probe routing (objectui#4114)', () => { render( ({ data: [] }) }} + dataSource={{ find: async () => ({ data: [] }) } as unknown as DataSource} apiFetch={host.fn} > @@ -321,7 +334,7 @@ describe('ObjectChart — option-color probe routing (objectui#4114)', () => { rows: [{ status: 'todo', task_count: 3 }], fields: [{ name: 'status', label: 'Status' }, { name: 'task_count', label: 'Tasks' }], }), - }} + } as unknown as DataSource} apiFetch={host.fn} > { const globalCalls = installMetaFetchDouble(OPPORTUNITY_DOC); render( - ({ data: [] }) }}> + ({ data: [] }) } as unknown as DataSource}> , ); diff --git a/packages/plugin-dashboard/src/__tests__/DashboardChart.categoryAxisKey-8269.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardChart.categoryAxisKey-8269.test.tsx index b431156506..ed7b04d872 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardChart.categoryAxisKey-8269.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardChart.categoryAxisKey-8269.test.tsx @@ -70,6 +70,19 @@ import '@object-ui/components'; import '@object-ui/plugin-charts'; import '../index'; import { DashboardRenderer } from '../DashboardRenderer'; +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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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. + */ /** Every composed chart node the relays handed the renderer, in order. */ const composed: any[] = []; @@ -94,7 +107,7 @@ const dataSource = { aggregate: async () => [], find: async () => [] }; const composeVia = async (surface: 'grid' | 'renderer', widget: Record) => { composed.length = 0; render( - + {surface === 'grid' ? ( ) : ( diff --git a/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfig-4044.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfig-4044.test.tsx index 8bb05031b3..2220d09c0a 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfig-4044.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfig-4044.test.tsx @@ -71,6 +71,19 @@ import '@object-ui/components'; import '@object-ui/plugin-charts'; import '../index'; import { DashboardRenderer } from '../DashboardRenderer'; +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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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. + */ /** Every composed chart node the relays handed the renderer, in order. */ const composed: any[] = []; @@ -95,7 +108,7 @@ const dataSource = { aggregate: async () => [], find: async () => [] }; const composeVia = async (surface: 'grid' | 'renderer', widget: Record) => { composed.length = 0; render( - + {surface === 'grid' ? ( ) : ( diff --git a/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigDom-4044.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigDom-4044.test.tsx index f077752481..d55d5bbe0b 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigDom-4044.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigDom-4044.test.tsx @@ -57,6 +57,19 @@ import '@object-ui/components'; import '@object-ui/plugin-charts'; import '../index'; import { DashboardRenderer } from '../DashboardRenderer'; +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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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. + */ afterEach(cleanup); @@ -90,7 +103,7 @@ const renderWidget = async (surface: (typeof SURFACES)[number], chartConfig?: Re ...(chartConfig ? { chartConfig } : {}), }; const view = render( - + {surface === 'grid' ? ( ) : ( diff --git a/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigMarks-4044.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigMarks-4044.test.tsx index 498f8a4e90..466dcb4ae0 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigMarks-4044.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigMarks-4044.test.tsx @@ -59,6 +59,19 @@ import '@object-ui/components'; import '@object-ui/plugin-charts'; import '../index'; import { DashboardRenderer } from '../DashboardRenderer'; +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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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 PLOT_BOX = { width: 480, height: 320 } as const; const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; @@ -116,7 +129,7 @@ const renderWidget = async ( ...(chartConfig ? { chartConfig } : {}), }; const view = render( - + {surface === 'grid' ? ( ) : ( diff --git a/packages/plugin-dashboard/src/__tests__/DashboardChart.countSeriesKey-8266.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardChart.countSeriesKey-8266.test.tsx index 5aa53469c8..3eb49ca1de 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardChart.countSeriesKey-8266.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardChart.countSeriesKey-8266.test.tsx @@ -58,6 +58,19 @@ import '@object-ui/components'; import '@object-ui/plugin-charts'; import '../index'; import { DashboardRenderer } from '../DashboardRenderer'; +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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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. + */ /** Every composed chart node the relays handed the renderer, in order. */ const composed: any[] = []; @@ -82,7 +95,7 @@ const dataSource = { aggregate: async () => [], find: async () => [] }; const composeVia = async (surface: 'grid' | 'renderer', widget: Record) => { composed.length = 0; render( - + {surface === 'grid' ? ( ) : ( diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.relabel.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.relabel.test.tsx index 9e73bc589e..50dd7a85cc 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.relabel.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.relabel.test.tsx @@ -18,6 +18,19 @@ import { render, cleanup, waitFor } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRendererProvider, type ApiFetch } from '@object-ui/react'; import { DatasetWidget } from '../DatasetWidget'; +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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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. + */ let capturedChartSchema: any = null; beforeAll(() => { @@ -191,7 +204,7 @@ describe('DatasetWidget option-color / dimension-label probe routing (objectui#4 const host = makeMetaFetchRecorder(); render( - + , ); @@ -232,7 +245,7 @@ describe('DatasetWidget option-color / dimension-label probe routing (objectui#4 // provider is not presence of a channel, so this must stay on the fallback // rather than resolve to `undefined` and skip the read. render( - + , ); 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 02b89783ae..470d3e61b4 100644 --- a/packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ObjectDataTable.bindNotForwarded-6575.test.tsx @@ -76,6 +76,19 @@ vi.mock('@object-ui/react', async () => { }); import { ObjectDataTable } from '../ObjectDataTable'; +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. + */ afterEach(() => { cleanup(); @@ -101,7 +114,7 @@ const BOUND_SCHEMA = { function renderBound() { return render( - + , diff --git a/packages/plugin-detail/src/useRecordEditable.test.tsx b/packages/plugin-detail/src/useRecordEditable.test.tsx index 1ab2ac024a..efb3877d66 100644 --- a/packages/plugin-detail/src/useRecordEditable.test.tsx +++ b/packages/plugin-detail/src/useRecordEditable.test.tsx @@ -24,6 +24,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { SchemaRendererProvider } from '@object-ui/react'; import { useRecordEditable, __clearRecordEditableCache } from './useRecordEditable'; +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 placeholders for a provider that merely has to EXIST, and one probe + * that pins the empty-object case by name. + * 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. + */ function mockExplain(body: unknown, ok = true) { return vi.fn(async () => ({ ok, json: async () => body })) as any; @@ -105,7 +117,7 @@ describe('useRecordEditable', () => { vi.stubGlobal('fetch', globalFetch); const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); diff --git a/packages/plugin-gantt/src/index.tsx b/packages/plugin-gantt/src/index.tsx index b4822f69af..cf28d634e1 100644 --- a/packages/plugin-gantt/src/index.tsx +++ b/packages/plugin-gantt/src/index.tsx @@ -88,7 +88,13 @@ const OBJECT_GANTT_DATA_SOURCE: ElementDataSourceMapping = { // Register component export const ObjectGanttRenderer: React.FC<{ schema: any }> = elementDataSourceBlock(({ schema }) => { - const { dataSource } = useSchemaContext() || {}; + // `useSchemaContext()` may hand back a NULL adapter: a host with nothing + // bound spells absence either way, and the seam declares both + // (`DataSource | null | undefined`, objectui#7912). The widget below + // declares the single spelling `dataSource?: DataSource`, so collapse the + // two absences into that one here rather than widening the widget. + const { dataSource: contextDataSource } = useSchemaContext() || {}; + const dataSource = contextDataSource ?? undefined; // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). A // gantt authored with the binding and no flat `objectName` produced no data // config at all, so `resolveDataSource` had nothing to fetch through: an empty diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 584199f08b..da52ecb3cc 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -412,7 +412,13 @@ const OBJECT_KANBAN_DATA_SOURCE: ElementDataSourceMapping = { // Register object-kanban for ListView integration export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> = elementDataSourceBlock(({ schema, ...props }) => { - const { dataSource } = useSchemaContext() || {}; + // `useSchemaContext()` may hand back a NULL adapter: a host with nothing + // bound spells absence either way, and the seam declares both + // (`DataSource | null | undefined`, objectui#7912). The widget below + // declares the single spelling `dataSource?: DataSource`, so collapse the + // two absences into that one here rather than widening the widget. + const { dataSource: contextDataSource } = useSchemaContext() || {}; + const dataSource = contextDataSource ?? undefined; // The spec's `PageComponentSchema.dataSource` binding (objectstack#6953): // before this, a board authored with `dataSource: { object, view }` and no // `objectName` never fetched — the effect is gated on `schema.objectName` — diff --git a/packages/plugin-kanban/src/registration.test.tsx b/packages/plugin-kanban/src/registration.test.tsx index a99f7b0f9a..cac58ee2b8 100644 --- a/packages/plugin-kanban/src/registration.test.tsx +++ b/packages/plugin-kanban/src/registration.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import React from 'react'; import { ObjectKanbanRenderer } from './index'; +import type { DataSource } from '@object-ui/types'; // Partial mock — override ONLY what this test controls, keep every other real // export. Same conversion `plugin-calendar/src/registration.test.tsx` already @@ -14,7 +15,11 @@ import { ObjectKanbanRenderer } from './index'; vi.mock(import('@object-ui/react'), async (importOriginal) => ({ ...(await importOriginal()), // Only the piece this test drives: - useSchemaContext: vi.fn(() => ({ dataSource: { type: 'mock-datasource' } })), + // The marker object below is NOT an adapter: the stubbed widget prints + // `dataSource.type`, which is the whole point of this registration probe. + // `useSchemaContext` declares the published `DataSource` contract since + // objectui#7912, so the crossing is explicit; the value is unchanged. + useSchemaContext: vi.fn(() => ({ dataSource: { type: 'mock-datasource' } as unknown as DataSource })), })); // Mock the implementation diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index c4ccd6a96c..e319b13bad 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -11,7 +11,7 @@ import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeField import { ComponentRegistry, buildExpandFields, getRecordDisplayName, isEmptyValue } from '@object-ui/core'; import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components'; import { usePermissions } from '@object-ui/permissions'; -import type { GalleryConfig, ObjectGallerySchema } from '@object-ui/types'; +import type { DataSource, GalleryConfig, ObjectGallerySchema, QueryParams } from '@object-ui/types'; import { ChevronRight, ChevronDown } from 'lucide-react'; import { getCellRenderer, resolveCellRendererType, readFileValues } from '@object-ui/fields'; @@ -23,7 +23,15 @@ export interface ObjectGalleryProps { */ schema: ObjectGallerySchema; data?: Record[]; - dataSource?: { find: (name: string, query: unknown) => Promise }; + /** + * The host's adapter. Declared as the published `DataSource` contract + * (objectui#7912) — this used to be a hand-rolled `{ find(name, query: + * unknown): Promise }` stand-in, which is a second, weaker + * spelling of a type this repo already publishes: it accepted any object + * with a `find`, and it erased `find`'s real parameter and return types at + * every call below. + */ + dataSource?: DataSource; onCardClick?: (record: Record) => void; /** Callback when a row/item is clicked (overrides NavigationConfig) */ onRowClick?: (record: Record) => void; @@ -291,7 +299,10 @@ export const ObjectGallery: React.FC = (props) => { */ const { ready: objectDefReady, def: objectDef } = useSettledSchema( schema.objectName ?? '', - dataSource as any, + // No cast: `useSettledSchema` declares `DataSource | null | + // undefined` and, since objectui#7912, that is exactly what the seam + // hands over. + dataSource, ); // Permissions context, read here rather than inside the fetch effect below: @@ -428,15 +439,35 @@ export const ObjectGallery: React.FC = (props) => { ? expandable : expandable.filter((f) => perms.checkField(schema.objectName as string, f, 'read')); const results = await dataSource.find(schema.objectName, { - $filter: schema.filter, + // `ObjectGallerySchema.filter` is declared `unknown` — the + // one view schema in `@object-ui/types` whose `filter` is + // not `any[]` — and its docblock says it is "forwarded + // verbatim as `$filter`". Typing the adapter above makes + // `find`'s parameter real, so the verbatim forward has to + // name the parameter's own type instead of riding on + // `unknown`. Asserted, not coerced: the value is passed + // through byte-for-byte, exactly as before. + $filter: schema.filter as QueryParams['$filter'], ...(expand.length > 0 ? { $expand: expand } : {}), }); + // `find` now DECLARES `QueryResult`, whose only required + // member is `data` (objectui#7912 typed the adapter). This + // block predates that declaration and sniffs three envelopes: + // a bare array, `{ records }`, and the declared `{ data }`. + // + // Every branch is kept and every runtime path is unchanged. The + // declared value is widened ONCE, here, so the existing checks + // keep doing their own narrowing instead of being deleted on + // the strength of a declaration: whether any adapter really + // answers with the two UNDECLARED envelopes is a question about + // the adapters, and answering it is not this card's business. + const envelope: unknown = results; let data: Record[] = []; - if (Array.isArray(results)) { - data = results; - } else if (results && typeof results === 'object') { - const r = results as Record; + if (Array.isArray(envelope)) { + data = envelope; + } else if (envelope && typeof envelope === 'object') { + const r = envelope as Record; if (Array.isArray(r.records)) { data = r.records as Record[]; } else if (Array.isArray(r.data)) { diff --git a/packages/plugin-list/src/__tests__/ListView.ariaLabelInlineLocale.test.tsx b/packages/plugin-list/src/__tests__/ListView.ariaLabelInlineLocale.test.tsx index b46f7f66e2..3eeba87f01 100644 --- a/packages/plugin-list/src/__tests__/ListView.ariaLabelInlineLocale.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.ariaLabelInlineLocale.test.tsx @@ -60,9 +60,21 @@ import { render, screen, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import { LocalizationProvider } from '@object-ui/i18n'; import { SchemaRendererProvider } from '@object-ui/react'; -import type { ListViewSchema } from '@object-ui/types'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; import { ListView } from '../ListView'; +/** + * 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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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 mockDataSource = { find: vi.fn().mockResolvedValue([]), findOne: vi.fn(), @@ -85,7 +97,7 @@ function renderListView(aria: unknown, locale: string) { return render( - + , diff --git a/packages/plugin-list/src/__tests__/ListView.density.offspec.test.tsx b/packages/plugin-list/src/__tests__/ListView.density.offspec.test.tsx index 6e7975e4f0..fd5921f2a5 100644 --- a/packages/plugin-list/src/__tests__/ListView.density.offspec.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.density.offspec.test.tsx @@ -9,9 +9,21 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import { SchemaRendererProvider } from '@object-ui/react'; -import type { ListViewSchema } from '@object-ui/types'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; import { ListView } from '../ListView'; +/** + * 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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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. + */ + /** * What an off-spec `rowHeight` actually RENDERS — the empirical half of * objectui#4440, pinned on the live path rather than argued from the mapping. @@ -53,7 +65,7 @@ const renderListView = (rowHeight?: unknown) => { } as ListViewSchema; return render( - + , ); diff --git a/packages/plugin-list/src/__tests__/ListView.descriptionInlineLocale-7199.test.tsx b/packages/plugin-list/src/__tests__/ListView.descriptionInlineLocale-7199.test.tsx index 724cce7a75..3f8b459f53 100644 --- a/packages/plugin-list/src/__tests__/ListView.descriptionInlineLocale-7199.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.descriptionInlineLocale-7199.test.tsx @@ -75,9 +75,21 @@ import { render, screen, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import { LocalizationProvider } from '@object-ui/i18n'; import { SchemaRendererProvider } from '@object-ui/react'; -import type { ListViewSchema } from '@object-ui/types'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; import { ListView } from '../ListView'; +/** + * 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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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 mockDataSource = { find: vi.fn().mockResolvedValue([]), findOne: vi.fn(), @@ -111,7 +123,7 @@ function renderDescription( return render( - + , diff --git a/packages/plugin-list/src/__tests__/ListView.overlayTitleI18n.test.tsx b/packages/plugin-list/src/__tests__/ListView.overlayTitleI18n.test.tsx index 7f0d02fbcd..09b45357fb 100644 --- a/packages/plugin-list/src/__tests__/ListView.overlayTitleI18n.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.overlayTitleI18n.test.tsx @@ -51,7 +51,19 @@ import { ComponentRegistry } from '@object-ui/core'; import { I18nProvider } from '@object-ui/i18n'; import { SchemaRendererProvider } from '@object-ui/react'; import { ListView } from '../ListView'; -import type { ListViewSchema } from '@object-ui/types'; +import type { DataSource, ListViewSchema } 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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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 rows = [{ id: '1', name: 'Alice' }]; @@ -89,7 +101,7 @@ afterEach(() => cleanup()); function renderListIn(language: string, schemaExtra: Partial) { return render( - + { it('reuses detail.recordDetail when the schema names nothing', async () => { render( - + cleanup()); function renderList(schemaExtra: Record) { return render( - + { let store: Record = {}; @@ -34,7 +46,7 @@ const mockDataSource = { const renderWithProvider = (component: React.ReactNode) => { return render( - + {component} ); diff --git a/packages/plugin-list/src/__tests__/UserFilters.addTab.test.tsx b/packages/plugin-list/src/__tests__/UserFilters.addTab.test.tsx index 44386aede8..02dc6ff4e1 100644 --- a/packages/plugin-list/src/__tests__/UserFilters.addTab.test.tsx +++ b/packages/plugin-list/src/__tests__/UserFilters.addTab.test.tsx @@ -30,6 +30,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import { SchemaRendererProvider } from '@object-ui/react'; import { UserFilters } from '../UserFilters'; +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 partial stubs carrying only the members the path under test calls; + * completing them would change which capability probes fire, and so would + * change what these tests measure. + * 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. + */ afterEach(() => { cleanup(); @@ -211,7 +224,7 @@ describe('UserFilters tabs — adding a tab writes nothing (ADR-0047)', () => { setItem.mockClear(); render( - + ``` +`dataSource` carries the host's adapter as the published `DataSource` contract +(`@object-ui/types`), typed `DataSource | null | undefined`: a host either hands +over an adapter or states that it has none — a Studio preview, a page rendered +before the host's adapter has connected, or a widget probe driving `apiFetch` +alone. Both spellings of "none" are accepted because every reader in the tree +guards for it. The prop used to be typed `any`, so a string passed where the +adapter belongs — the shape of a config value read from the wrong place — +raised nothing until the first `find()` at runtime (objectui#7912). +`useSchemaContext()` hands the same type back to every consumer. + Nested providers inherit `apiFetch` from their parent when they don't supply their own, so re-wrapped subtrees (embedded pages, preview surfaces) keep the host's authentication. diff --git a/packages/react/src/__tests__/SchemaRenderer.debug.test.tsx b/packages/react/src/__tests__/SchemaRenderer.debug.test.tsx index f9e0af8191..f05938c314 100644 --- a/packages/react/src/__tests__/SchemaRenderer.debug.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.debug.test.tsx @@ -12,6 +12,18 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererProvider } from '../context/SchemaRendererContext'; +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 placeholders for a provider that merely has to EXIST, and one probe + * that pins the empty-object case by name. + * 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. + */ // Suppress console.warn from deprecated namespace registration const originalWarn = console.warn; @@ -35,7 +47,7 @@ describe('SchemaRenderer debug attributes', () => { it('should NOT inject data-debug-* attributes when debug is off', () => { const { getByTestId } = render( - + , ); @@ -46,7 +58,7 @@ describe('SchemaRenderer debug attributes', () => { it('should inject data-debug-type when debug is enabled', () => { const { getByTestId } = render( - + , ); @@ -57,7 +69,7 @@ describe('SchemaRenderer debug attributes', () => { it('should inject data-debug-type when debugFlags.enabled is true', () => { const { getByTestId } = render( - + , ); diff --git a/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx b/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx index 68b6ab1b6a..ee70f86461 100644 --- a/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.disabledDeclaredGate.test.tsx @@ -81,6 +81,19 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererContext } from '../context/SchemaRendererContext'; +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. + */ /** * Records the `disabled` prop EXACTLY as it arrives — `absent` when the renderer @@ -97,7 +110,7 @@ const DATA = { status: 'locked', readOnly: true, unlocked: false }; function renderNode(schema: Record) { return render( - + , ); diff --git a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx index 0f45d0b1db..a638a818da 100644 --- a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx @@ -23,6 +23,19 @@ import { SchemaRenderer } from '../SchemaRenderer'; // the same reason), and the two casts are gone. The `BaseSchema` import went // with them — nothing in this file needs the name any more. import { SchemaRendererContext } from '../context/SchemaRendererContext'; +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. + */ // Simple test component const TestComponent = (props: any) => ( @@ -53,7 +66,7 @@ describe('SchemaRenderer Expression Integration', () => { it('evaluates visible expression string', () => { render( - + ); @@ -62,7 +75,7 @@ describe('SchemaRenderer Expression Integration', () => { it('hides when visible expression evaluates to false', () => { const { container } = render( - + ); @@ -81,7 +94,7 @@ describe('SchemaRenderer Expression Integration', () => { it('hides with hiddenOn expression', () => { const { container } = render( - + ); @@ -99,7 +112,7 @@ describe('SchemaRenderer Expression Integration', () => { describe('visibleWhen (ADR-0089 canonical)', () => { it('shows when the visibleWhen predicate is truthy', () => { render( - + ); @@ -108,7 +121,7 @@ describe('SchemaRenderer Expression Integration', () => { it('hides when the visibleWhen predicate is falsy', () => { const { container } = render( - + ); @@ -117,7 +130,7 @@ describe('SchemaRenderer Expression Integration', () => { it('still honors the deprecated `visibility` alias', () => { const { container } = render( - + ); @@ -133,7 +146,7 @@ describe('SchemaRenderer Expression Integration', () => { it('evaluates disabled expression string', () => { render( - + ); @@ -142,7 +155,7 @@ describe('SchemaRenderer Expression Integration', () => { it('does not set disabled when expression is false', () => { render( - + ); @@ -151,7 +164,7 @@ describe('SchemaRenderer Expression Integration', () => { it('evaluates disabledOn expression', () => { render( - + ); diff --git a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx index d53b7a123b..630b348974 100644 --- a/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.hiddenDeclaredGate.test.tsx @@ -89,10 +89,22 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import type { BaseSchema } from '@object-ui/types'; +import type { BaseSchema, DataSource } from '@object-ui/types'; import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererContext } from '../context/SchemaRendererContext'; +/** + * 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. + */ + /** * Records the `hidden` prop exactly as it arrives, so "the node rendered" and * "the schema key leaked into the DOM props" are separate observations. @@ -108,7 +120,7 @@ const DATA = { status: 'draft', archived: true, published: false }; function renderNode(schema: Record) { return render( - + , ); @@ -139,7 +151,7 @@ function renderNode(schema: Record) { */ function renderDeclaredNode(schema: BaseSchema) { return render( - + , ); diff --git a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx index 58896ba1f1..8fe4e76ba0 100644 --- a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx @@ -56,6 +56,19 @@ import React from 'react'; import { ComponentRegistry, ExpressionEvaluator } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererContext } from '../context/SchemaRendererContext'; +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 Probe = (props: { schema?: { props?: Record } }) => (
+ , ); diff --git a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx index 487e8628c4..fa67d9dec6 100644 --- a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx @@ -62,10 +62,22 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import type { BaseSchema, ExpressionWire } from '@object-ui/types'; +import type { BaseSchema, DataSource, ExpressionWire } from '@object-ui/types'; import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererContext } from '../context/SchemaRendererContext'; +/** + * 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. + */ + /** * Records the `disabled` prop exactly as it arrives, so "the node rendered" and * "the renderer forwarded `disabled`" are separate observations. @@ -82,7 +94,7 @@ 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.validation.test.tsx b/packages/react/src/__tests__/SchemaRenderer.validation.test.tsx index 3f28bd9391..49bef1f89b 100644 --- a/packages/react/src/__tests__/SchemaRenderer.validation.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.validation.test.tsx @@ -12,6 +12,18 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererProvider } from '../context/SchemaRendererContext'; +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 placeholders for a provider that merely has to EXIST, and one probe + * that pins the empty-object case by name. + * 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 PassthroughDiv: React.FC = (props) => { const { schema, ...rest } = props; @@ -37,7 +49,7 @@ describe('SchemaRenderer — dev-mode validation', () => { it('does not warn for a well-formed schema', () => { render( - + ); @@ -58,7 +70,7 @@ describe('SchemaRenderer — dev-mode validation', () => { ComponentRegistry.register('host-wrap', HostWithChild); render( - + { children: [bad], }; const { getByTestId } = render( - + ); @@ -106,7 +118,7 @@ describe('SchemaRenderer — dev-mode validation', () => { }; const { rerender } = render( - + ); @@ -115,7 +127,7 @@ describe('SchemaRenderer — dev-mode validation', () => { ).length; rerender( - + ); diff --git a/packages/react/src/__tests__/SchemaRendererProvider.smoke.test.tsx b/packages/react/src/__tests__/SchemaRendererProvider.smoke.test.tsx index 24751616f1..f9f16a17e3 100644 --- a/packages/react/src/__tests__/SchemaRendererProvider.smoke.test.tsx +++ b/packages/react/src/__tests__/SchemaRendererProvider.smoke.test.tsx @@ -21,6 +21,18 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer } from '../SchemaRenderer'; import { SchemaRendererProvider, useSchemaContext } from '../context/SchemaRendererContext'; +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 placeholders for a provider that merely has to EXIST, and one probe + * that pins the empty-object case by name. + * 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. + */ // Suppress console.error from React error boundary during tests const originalConsoleError = console.error; @@ -46,7 +58,7 @@ describe('useSchemaContext provider requirement', () => { it('should not throw when used inside SchemaRendererProvider', () => { render( - + ); @@ -55,7 +67,7 @@ describe('useSchemaContext provider requirement', () => { it('should fall back to empty dataSource when provider has empty object', () => { render( - + ); @@ -77,8 +89,8 @@ describe('SchemaRendererProvider apiFetch inheritance (#2725)', () => { it('nested provider without apiFetch inherits the parent host fetch', () => { render( - - + + @@ -88,8 +100,8 @@ describe('SchemaRendererProvider apiFetch inheritance (#2725)', () => { it('nested provider with its own apiFetch overrides the parent', () => { render( - - + + @@ -99,7 +111,7 @@ describe('SchemaRendererProvider apiFetch inheritance (#2725)', () => { it('apiFetch stays undefined when no provider supplies one', () => { render( - + ); @@ -118,7 +130,7 @@ describe('SchemaRenderer + SchemaRendererProvider integration', () => { it('should render a component that calls useSchemaContext without error when provider wraps the tree', () => { render( - + ); @@ -160,7 +172,7 @@ describe('Plugin component types render inside provider', () => { // Render via SchemaRenderer inside provider const { container } = render( - + ); diff --git a/packages/react/src/context/SchemaRendererContext.tsx b/packages/react/src/context/SchemaRendererContext.tsx index 55efccde74..9c2e3357f7 100644 --- a/packages/react/src/context/SchemaRendererContext.tsx +++ b/packages/react/src/context/SchemaRendererContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useMemo } from 'react'; import type { DebugFlags } from '@object-ui/core'; +import type { DataSource } from '@object-ui/types'; /** * Host-provided fetch used for `provider: 'api'` view data sources so custom @@ -10,7 +11,29 @@ import type { DebugFlags } from '@object-ui/core'; export type ApiFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; interface SchemaRendererContextType { - dataSource: any; + /** + * The adapter the host injected, as the published `DataSource` contract + * declares it (objectui#7912). + * + * Typed, not `any`: this is the injection point for the ENTIRE renderer tree, + * and `useSchemaContext()` hands whatever is declared here to every consumer + * that reads it back. While it was `any`, a bare string passed where the + * host's adapter belongs raised nothing at compile time and failed at runtime + * on the first `find()` — the shape of a config value read from the wrong + * place. The type was never unknown: `@object-ui/types` exports `DataSource`, + * `useSettledSchema` in this same package already declares its parameter as + * `DataSource | null | undefined`, and `app-shell`'s README writes + * `DataSource` against this very seam. + * + * `null | undefined` are part of the contract, not a weakening of it: a host + * renders with no adapter bound (a Studio preview, a react page before the + * AdapterProvider connects, a widget test that drives `apiFetch` alone), and + * every reader in the tree already guards for it. The union states the three + * states that actually occur and refuses everything else — a string, `{}`, a + * plain data bag, a partial adapter missing a required member. It is spelled + * exactly as `useSettledSchema` spells it, one hop away. + */ + dataSource: DataSource | null | undefined; debug?: boolean; debugFlags?: DebugFlags; apiFetch?: ApiFetch; @@ -28,7 +51,8 @@ export const SchemaRendererProvider = ({ apiFetch, }: { children: React.ReactNode; - dataSource: any; + /** The host's adapter — see {@link SchemaRendererContextType.dataSource}. */ + dataSource: DataSource | null | undefined; debug?: boolean; debugFlags?: DebugFlags; apiFetch?: ApiFetch; @@ -62,6 +86,21 @@ export const useDataScope = (path?: string) => { const dataSource = context?.dataSource; if (!path) return undefined; if (!dataSource) return undefined; - // Simple path resolution for now. In real app might be more complex - return path.split('.').reduce((acc, part) => acc && acc[part], dataSource); + // 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); } diff --git a/packages/react/src/context/__tests__/SchemaRendererContext.dataSourceType.pin.test.ts b/packages/react/src/context/__tests__/SchemaRendererContext.dataSourceType.pin.test.ts new file mode 100644 index 0000000000..3d2a413df5 --- /dev/null +++ b/packages/react/src/context/__tests__/SchemaRendererContext.dataSourceType.pin.test.ts @@ -0,0 +1,260 @@ +/** + * 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. + */ + +/** + * `SchemaRendererProvider`'s `dataSource` prop AND the context type the whole + * tree reads back are the published `DataSource` contract, and the compiler + * knows it (objectui#7912). + * + * ## What went wrong, and why only the compiler can see it + * + * Both members were declared `any`: the provider's own prop, and + * `SchemaRendererContextType.dataSource`, which `useSchemaContext()` hands to + * every consumer in the tree. So a bare string passed where the host's adapter + * belongs raised nothing — and that is not a hypothetical mistake, it is the + * shape of a config value read from the wrong place, failing at runtime on the + * first `find()`. The type was never unknown: `@object-ui/types` exports + * `DataSource`, `useSettledSchema` in this same package already declared its + * parameter as `DataSource | null | undefined`, and `app-shell`'s README + * writes `DataSource` against this very seam. + * + * A runtime test cannot observe any of this. Which assignments the compiler + * refuses is erased before an assertion could run, and while the seam was + * `any` every wrong value succeeded at runtime until the first adapter call. + * So the gauge has to be `tsc`, driven here — the same harness as + * `RecordContext.dataSourceType.pin.test.ts` (objectui#9197), for the same + * reason. + * + * ## Pinned in BOTH directions, through the PUBLISHED surface + * + * The rows do not name the internal interface. They go through + * `Parameters[0]` and `ReturnType`, because "no consumer can be told anything about it" is + * the defect, and those two are what a consumer can reach. + * + * - ACCEPTED: a host may hand over an adapter with no cast; a host with nothing + * bound may say so (`undefined` / `null` — the Studio preview, a react page + * before its AdapterProvider connects, a widget test driving `apiFetch` + * alone); a reader may reach `find` off the context after guarding. + * - REFUSED: the card's own planted probe (`'not-an-adapter'`), the empty + * object that used to be passed as a "no adapter" stand-in, a partial adapter + * missing a REQUIRED member, and a plain data bag. These rows are the + * acceptance of objectui#7912: each one is silently accepted by `any`, so + * reverting either declaration turns them green and this file red. + * + * ## Resolution guard (why the CONTROL rows exist) + * + * The harness resolves the context from its SOURCE path and `@object-ui/types` + * through the repo's `paths`, not through `dist`. A type-level pin's + * characteristic failure is the harness degrading everything to `any`: every + * REFUSED row would flip to accepted while the file still looks perfect. The + * CONTROL rows are refusals that have nothing to do with `dataSource` — a + * misspelled prop key, a missing required prop, and `DataSource`'s required + * members being visible at all — so if resolution ever stops being real, they + * fire. Any diagnostic landing in the virtual module's IMPORT HEADER throws + * loudly instead of being read as a verdict. + * + * Cost note (AGENTS.md 测试纪律): the program is built at MODULE SCOPE, so the + * compiler work lands in the import phase, which no test or hook timeout + * bounds. A `beforeAll` would put it under the narrower 10s `hookTimeout`. + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +// __tests__ → context → src → react → packages → repo root +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..', '..'); +/** The seam under test, by SOURCE path — no `dist`, no barrel. */ +const CONTEXT_IMPORT = join(HERE, '..', 'SchemaRendererContext').replace(/\\/g, '/'); + +/** + * One assignment or read, and whether `tsc` must refuse it. + * `code` emits EXACTLY one line — the line index is how a diagnostic is + * attributed back to a case. + */ +interface Case { + readonly what: string; + readonly code: string; + readonly refused: boolean; +} + +/** The context value as a CONSUMER reaches it. */ +const CTX = `(undefined as unknown as ReturnType)`; +/** The provider's props as a CONSUMER reaches them. */ +const PROPS = `Parameters[0]`; +/** A complete adapter, for rows that are not about completeness. */ +const ADAPTER = `(undefined as unknown as DataSource)`; + +const CASES: readonly Case[] = [ + // ── The acceptance of objectui#7912. These rows ARE the card. ───────────── + { + what: 'a host may hand the provider a DataSource adapter with no cast', + code: `const c: ${PROPS} = { children: null, dataSource: ${ADAPTER} };`, + refused: false, + }, + { + what: 'a host with no adapter bound may say so with `undefined`', + code: `const c: ${PROPS} = { children: null, dataSource: undefined };`, + refused: false, + }, + { + what: 'a host with no adapter bound may say so with `null`', + code: `const c: ${PROPS} = { children: null, dataSource: null };`, + refused: false, + }, + { + what: 'a reader reaches `find` off the context value with no cast, once guarded', + code: `const c: DataSource['find'] = ${CTX}.dataSource!.find;`, + refused: false, + }, + { + what: 'the context member reads as the adapter contract, absences included', + code: `const c: DataSource | null | undefined = ${CTX}.dataSource;`, + refused: false, + }, + { + what: 'the seam is one type on both sides — the prop is assignable to the context member', + code: `const c: ReturnType['dataSource'] = (undefined as unknown as ${PROPS})['dataSource'];`, + refused: false, + }, + + // ── The breaking half. Each of these is what `any` used to accept. ──────── + { + what: "BREAKING the card's planted probe — a bare string where the adapter belongs", + code: `const c: ${PROPS} = { children: null, dataSource: 'not-an-adapter' };`, + refused: true, + }, + { + what: 'BREAKING the context member no longer reads as a string', + code: `const c: string | null | undefined = ${CTX}.dataSource;`, + refused: true, + }, + { + what: 'BREAKING an empty object is not a "no adapter" stand-in any more', + code: `const c: ${PROPS} = { children: null, dataSource: {} };`, + refused: true, + }, + { + what: 'BREAKING a partial adapter missing a REQUIRED member is refused', + code: `const c: ${PROPS} = { children: null, dataSource: { find: (undefined as any), findOne: (undefined as any), create: (undefined as any), update: (undefined as any), delete: (undefined as any) } };`, + refused: true, + }, + { + what: 'BREAKING a plain data bag is refused where the adapter belongs', + code: `const c: ${PROPS} = { children: null, dataSource: { users: [] } };`, + refused: true, + }, + + // ── Controls. Nothing to do with `dataSource`; they prove the program is + // resolving the real types rather than degrading everything to `any`. ── + { + what: 'CONTROL a misspelled provider prop key is refused', + code: `const c: ${PROPS} = { children: null, dataSource: ${ADAPTER}, dataSorce: ${ADAPTER} };`, + refused: true, + }, + { + what: 'CONTROL a provider value missing the required `children` is refused', + code: `const c: ${PROPS} = { dataSource: ${ADAPTER} };`, + refused: true, + }, + { + what: 'CONTROL `DataSource` really declares `getObjectSchema` as REQUIRED', + code: `const c: DataSource = { find: (undefined as any), findOne: (undefined as any), create: (undefined as any), update: (undefined as any), delete: (undefined as any) };`, + refused: true, + }, +]; + +const IMPORTS = [ + `import type { DataSource } from '@object-ui/types';`, + `import type { SchemaRendererProvider, useSchemaContext } from '${CONTEXT_IMPORT}';`, + // Keeps the imports "used", so a reader of the virtual file can see why they + // are here even when a case stops mentioning one of them. + `type _Used = [DataSource, typeof SchemaRendererProvider, typeof useSchemaContext];`, +].join('\n'); + +/** + * Compile every case and return the set of case indices that produced a + * diagnostic. + * + * `paths` mirrors the repo root `tsconfig.json`, so the workspace specifiers + * resolve to source exactly as the workspace itself resolves them. See the file + * header for why default (`dist`-backed) resolution is not acceptable here. + */ +function erroringCases(): Set { + const header = `${IMPORTS}\n`; + // Each case is wrapped in its own BLOCK so the `const c` declarations do not + // collide — a duplicate-identifier diagnostic would land on every line and + // read as "the compiler refuses everything", which is the one wrong answer + // this file must never produce. Still exactly one line per case. + const body = CASES.map((c) => `{ ${c.code} }`).join('\n'); + const source = `${header}${body}\n`; + const headerLines = header.split('\n').length - 1; + + const VIRTUAL = join(HERE, '__schemaRendererDataSourcePins.virtual.ts').replace(/\\/g, '/'); + const options: ts.CompilerOptions = { + strict: true, + skipLibCheck: true, + noEmit: true, + jsx: ts.JsxEmit.ReactJSX, + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + baseUrl: REPO_ROOT, + paths: { + '@object-ui/types': ['packages/types/src'], + '@object-ui/types/*': ['packages/types/src/*'], + '@object-ui/core': ['packages/core/src'], + '@object-ui/core/*': ['packages/core/src/*'], + }, + }; + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, ...rest) => + fileName === VIRTUAL + ? ts.createSourceFile(fileName, source, languageVersion, true) + : getSourceFile(fileName, languageVersion, ...rest); + const fileExists = host.fileExists.bind(host); + host.fileExists = (fileName) => (fileName === VIRTUAL ? true : fileExists(fileName)); + const readFile = host.readFile.bind(host); + host.readFile = (fileName) => (fileName === VIRTUAL ? source : readFile(fileName)); + + const program = ts.createProgram([VIRTUAL], options, host); + const sf = program.getSourceFile(VIRTUAL); + if (!sf) throw new Error('virtual source file was not added to the program'); + + const cases = new Set(); + for (const d of [...program.getSemanticDiagnostics(sf), ...program.getSyntacticDiagnostics(sf)]) { + if (d.start == null) continue; + const index = sf.getLineAndCharacterOfPosition(d.start).line - headerLines; + // A diagnostic ABOVE the first case line is a broken import, not a verdict. + // Fail loudly rather than let it read as "the compiler accepted everything". + if (index < 0) { + throw new Error( + 'the pin harness failed to resolve its own imports — this is a setup failure, not a ' + + `verdict about \`dataSource\`: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`, + ); + } + cases.add(index); + } + return cases; +} + +// Module scope on purpose — see the file header. +const refusedByCompiler = erroringCases(); + +describe('`SchemaRendererProvider.dataSource` is the published DataSource contract (objectui#7912)', () => { + for (const [i, c] of CASES.entries()) { + it(c.what, () => { + expect({ case: c.what, refused: refusedByCompiler.has(i) }) + .toEqual({ case: c.what, refused: c.refused }); + }); + } +}); diff --git a/packages/react/src/context/__tests__/useDataScope.test.tsx b/packages/react/src/context/__tests__/useDataScope.test.tsx index deff53df44..acd92221a7 100644 --- a/packages/react/src/context/__tests__/useDataScope.test.tsx +++ b/packages/react/src/context/__tests__/useDataScope.test.tsx @@ -6,11 +6,23 @@ import { describe, it, expect } from 'vitest'; import { renderHook } from '@testing-library/react'; import React from 'react'; import { SchemaRendererProvider, useDataScope } from '../SchemaRendererContext'; +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 — + * `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. + */ describe('useDataScope', () => { it('returns undefined when no path is provided', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); @@ -22,7 +34,7 @@ describe('useDataScope', () => { it('returns undefined when path is empty string', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); @@ -34,7 +46,7 @@ describe('useDataScope', () => { it('returns scoped data when a valid path is given', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); @@ -46,7 +58,7 @@ describe('useDataScope', () => { it('resolves nested paths', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); @@ -58,7 +70,7 @@ describe('useDataScope', () => { it('returns undefined for non-existent path', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); @@ -78,7 +90,7 @@ describe('useDataScope', () => { // Simulate the real scenario: dataSource is a service adapter with methods const adapter = { find: () => {}, create: () => {}, update: () => {} }; const wrapper = ({ children }: { children: React.ReactNode }) => ( - + {children} ); diff --git a/packages/react/src/hooks/useClientNotifications.ts b/packages/react/src/hooks/useClientNotifications.ts index 4fe5a15075..1f090e62f8 100644 --- a/packages/react/src/hooks/useClientNotifications.ts +++ b/packages/react/src/hooks/useClientNotifications.ts @@ -31,6 +31,7 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { SchemaRendererContext } from '../context/SchemaRendererContext.js'; import { useNotifications } from '../context/NotificationContext.js'; import type { NotificationSeverityLevel } from '../context/NotificationContext.js'; +import type { DataSource } from '@object-ui/types'; /* ------------------------------------------------------------------ */ /* Public types */ @@ -69,6 +70,23 @@ function toSeverity(value: unknown): NotificationSeverityLevel { return 'info'; } +/** + * An adapter that wraps an `@objectstack/client` and hands it out. + * + * `getClient` is NOT a `DataSource` member and this declaration does not make + * it one: it is an ObjectStackAdapter CAPABILITY that only some adapters have, + * so it is declared here, next to its only reader, and probed structurally. + * Declaring the capability is what lets the seam keep its real type — the + * alternative, casting the context value back to `any` to reach a member the + * contract does not promise, would re-create objectui#7912's defect one line + * below the seam it just fixed. + */ +type ClientBearingDataSource = DataSource & { getClient(): unknown }; + +function hasGetClient(dataSource: DataSource): dataSource is ClientBearingDataSource { + return typeof (dataSource as Partial).getClient === 'function'; +} + /** * Resolve the ObjectStack client from explicit option or SchemaRendererContext. * @@ -81,7 +99,7 @@ function useResolvedClient(explicit?: any): any { if (explicit) return explicit; const dataSource = rendererCtx?.dataSource; - if (dataSource && typeof dataSource.getClient === 'function') { + if (dataSource && hasGetClient(dataSource)) { return dataSource.getClient(); } return null;