From 79e9b6a55eec5064edf404b0d897229dee58bef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:37:16 +0000 Subject: [PATCH 1/2] fix(core): one shared record-source object-name reader, six plugins delegate Six view plugins each spelled "the object this block is bound to" locally -- ObjectCalendar twice, ObjectGantt, ObjectTree twice, ObjectMap, ObjectGrid -- and had drifted: three wrote `?? schema.objectName`, one `|| ''`, one `: undefined`, one an `'object' in dataConfig` test. `@object-ui/core` now publishes `resolveRecordSourceObjectName`, which states the objectui#6939 record-source ladder once, and those sites delegate to it. Behaviour-neutral, measured rather than asserted: each site's pre-collapse expression is transcribed verbatim into `record-source.behaviourNeutrality-7627.test.ts` and asserted equal to its post-collapse spelling across the whole contract-valid input matrix. Two separately-ruled questions stay two. `normalizeListViewSchema`'s gap-fill (#7477, PR #7628 ruling B) is untouched and is not re-pointed at the new reader: it answers how `objectName` gets POPULATED when absent, where an already-present `objectName` wins. The new reader answers which object a block RESOLVES, where the `data` block wins -- the order declared on both published faces in `@object-ui/types`. `ObjectGantt`'s `persistLayoutKey` is excluded from the collapse and keeps its inverted order, with an in-place comment saying why: its receiver is a localStorage key (`gantt-layout:KEY:filters`), not a record source. `useSettledSchema`'s doc comment stops prescribing the hand-written ladder at all four lines that taught it. Fixes #7627 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .../7627-shared-record-source-reader.md | 43 +++++ packages/core/src/index.ts | 9 + ...rd-source.behaviourNeutrality-7627.test.ts | 169 ++++++++++++++++++ packages/core/src/utils/record-source.ts | 76 ++++++++ .../plugin-calendar/src/ObjectCalendar.tsx | 6 +- packages/plugin-gantt/src/ObjectGantt.tsx | 13 +- packages/plugin-grid/src/ObjectGrid.tsx | 11 +- packages/plugin-map/src/ObjectMap.tsx | 21 ++- packages/plugin-tree/src/ObjectTree.tsx | 15 +- packages/react/src/hooks/useSettledSchema.ts | 26 +-- 10 files changed, 363 insertions(+), 26 deletions(-) create mode 100644 .changeset/7627-shared-record-source-reader.md create mode 100644 packages/core/src/utils/__tests__/record-source.behaviourNeutrality-7627.test.ts create mode 100644 packages/core/src/utils/record-source.ts diff --git a/.changeset/7627-shared-record-source-reader.md b/.changeset/7627-shared-record-source-reader.md new file mode 100644 index 0000000000..ac7d4d29df --- /dev/null +++ b/.changeset/7627-shared-record-source-reader.md @@ -0,0 +1,43 @@ +--- +'@object-ui/core': minor +'@object-ui/plugin-calendar': minor +'@object-ui/plugin-gantt': minor +'@object-ui/plugin-grid': minor +'@object-ui/plugin-map': minor +'@object-ui/plugin-tree': minor +'@object-ui/react': minor +--- + +`@object-ui/core` publishes `resolveRecordSourceObjectName`, the ONE reader for "which +object is this block bound to" (objectui#7627). + +Six view plugins each spelled that resolution locally — `ObjectCalendar` twice, +`ObjectGantt`, `ObjectTree` twice, `ObjectMap`, `ObjectGrid` — and had drifted: three +wrote `?? schema.objectName`, one `|| ''`, one `: undefined`, one an `'object' in +dataConfig` test. They now delegate to one function that states the published +objectui#6939 record-source ladder (`data`, then `staticData`, then `objectName`) once. + +**No behaviour changes.** Each site's pre-collapse expression is transcribed verbatim +into `record-source.behaviourNeutrality-7627.test.ts` and asserted equal to its +post-collapse spelling across the whole contract-valid input matrix — both bindings +present, data only, `objectName` only, empty `objectName`, empty `data.object`, the +`api` / `value` / `staticData` / array-shorthand providers, and nothing bound. + +**Two questions stay two questions.** `normalizeListViewSchema`'s gap-fill (#7477, +ruling B of PR #7628) is untouched and is NOT re-pointed at the new reader: it answers +how `objectName` gets POPULATED when absent, where an already-present `objectName` wins. +The new reader answers which object a block RESOLVES, where the `data` block wins — the +order declared on both published faces in `@object-ui/types` and pinned by +`objectql-record-source-refinement-6939.test.ts`. Merging them would silently override +one standing ruling or the other. + +**`ObjectGantt`'s `persistLayoutKey` is deliberately excluded** and keeps its inverted +order, with an in-place comment saying why: its receiver is a localStorage key +(`gantt-layout:KEY:filters`), not a record source, so re-pointing it would orphan every +saved layout and filter-chip set of a view carrying both bindings. Two more sites the +finding listed are not object-name readers at all and were struck: `ObjectGantt`'s +refresh-handler predicate (`object` OR `api`) and `plugin-dashboard`'s `isObjectProvider` +type-guard over a widget's `data`. + +`useSettledSchema`'s doc comment stops prescribing the hand-written ladder at all four +lines that taught it, so the copies cannot re-seed from the hook that replaced them. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4b84a7d034..ed74e676ae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,6 +112,15 @@ export * from './utils/predicate-fields.js'; // may group by a field it never shows (objectui#7179). export * from './utils/grouping-fields.js'; export * from './utils/normalize-list-view.js'; +// The ONE record-source object-name reader (objectui#7627). Six view plugins +// each spelled "the object this block is bound to — the resolved data config's +// object when it names one, else `objectName`" locally, and had drifted. It is +// deliberately SEPARATE from the `normalizeListViewSchema` gap-fill above: +// that one answers how `objectName` gets POPULATED when absent (#7477 ruling +// B), this one answers which object a block RESOLVES (the objectui#6939 +// three-rung ladder). Merging them would override one standing ruling or the +// other. +export * from './utils/record-source.js'; // The single home for the VALUE fallback prettifier (a stored value becomes a // display string when nothing resolves it). `@object-ui/fields` and // `@object-ui/plugin-charts` each carried a byte-identical private copy; diff --git a/packages/core/src/utils/__tests__/record-source.behaviourNeutrality-7627.test.ts b/packages/core/src/utils/__tests__/record-source.behaviourNeutrality-7627.test.ts new file mode 100644 index 0000000000..1e2fb2878d --- /dev/null +++ b/packages/core/src/utils/__tests__/record-source.behaviourNeutrality-7627.test.ts @@ -0,0 +1,169 @@ +/** + * objectui#7627 — the shared record-source reader is BEHAVIOUR-NEUTRAL at every + * site that delegates to it. + * + * Six view plugins each spelled "the object this block is bound to" locally and + * had drifted apart. Collapsing them onto {@link resolveRecordSourceObjectName} + * is only legitimate if it changes nothing any of them resolves, so this file + * TRANSCRIBES each site's pre-collapse expression verbatim and asserts the + * post-collapse spelling agrees with it across the whole contract-valid input + * matrix. A future edit to the reader that moves any site turns this red. + * + * The matrix is contract-valid by construction: `ViewDataSchema`'s `object` + * provider is a `strictObject` carrying exactly `{ provider, object }` with + * `object` REQUIRED, so `{ provider: 'object' }` without an `object` cannot be + * published. The two sites that used to coerce that off-contract shape back to + * `objectName` — `ObjectGrid`'s `'object' in dataConfig` test and `ObjectTree`'s + * header tail — keep their own tail at the site, so their behaviour is pinned + * here too, on both faces of the fork. + */ +import { describe, it, expect } from 'vitest'; +import { resolveRecordSourceObjectName } from '../record-source.js'; + +type Schema = { objectName?: string; data?: unknown; staticData?: unknown[] }; +type Cfg = { provider?: string; object?: string; items?: unknown[] } | null; + +// --- getDataConfig, transcribed from the plugins (objectui#7632 tracks the +// --- duplication of the PRODUCER; this is a copy for measurement only). +const getDataConfig = (schema: Schema): Cfg => { + if (schema.data) { + if (Array.isArray(schema.data)) return { provider: 'value', items: schema.data }; + return schema.data as Cfg; + } + if (schema.staticData) return { provider: 'value', items: schema.staticData }; + if (schema.objectName) return { provider: 'object', object: schema.objectName }; + return null; +}; + +/** Every read site, `before` transcribed verbatim from `origin/main` 11edab88. */ +const SITES: { + id: string; + before: (s: Schema, c: Cfg) => string | undefined; + after: (s: Schema, c: Cfg) => string | undefined; +}[] = [ + { + id: 'ObjectCalendar:309 schemaObjectName', + before: (s, c) => (c?.provider === 'object' ? c.object : s.objectName), + after: (s, c) => resolveRecordSourceObjectName(s, c), + }, + { + id: 'ObjectCalendar:969 overlay objectName', + before: (s, c) => (c?.provider === 'object' ? c.object : s.objectName), + after: (s, c) => resolveRecordSourceObjectName(s, c), + }, + { + id: 'ObjectGantt:661 resource', + // `??` binds tighter than `?:`, so the pre-collapse line parses as + // `cond ? c.object : (s.objectName ?? '')` — the empty-string floor applied + // to the FALLBACK arm only. + before: (s, c) => (c?.provider === 'object' ? c.object : s.objectName ?? ''), + after: (s, c) => resolveRecordSourceObjectName(s, c) ?? '', + }, + { + id: 'ObjectTree:373 schemaKey', + before: (s, c) => (c?.provider === 'object' ? c.object : s.objectName) ?? '', + after: (s, c) => resolveRecordSourceObjectName(s, c) ?? '', + }, + { + id: 'ObjectTree:567 headerObjectName', + before: (s, c) => (c?.provider === 'object' ? c.object : undefined) ?? s.objectName, + after: (s, c) => resolveRecordSourceObjectName(s, c) ?? s.objectName, + }, + { + id: 'ObjectMap:764 metadata objectName', + before: (s, c) => { + const dataProvider = c?.provider; + const dataObjectName = c?.provider === 'object' ? c.object : undefined; + return dataProvider === 'object' ? dataObjectName : s.objectName; + }, + after: (s, c) => resolveRecordSourceObjectName(s, c), + }, + { + id: 'ObjectGrid:1206 objectName', + before: (s, c) => (c?.provider === 'object' && c && 'object' in c ? c.object : s.objectName), + after: (s, c) => resolveRecordSourceObjectName(s, c) ?? s.objectName, + }, +]; + +/** The five shapes the dispatch named, plus every other one the ladder reaches. */ +const CONTRACT_VALID: [string, Schema][] = [ + ['both-bindings', { objectName: 'Y', data: { provider: 'object', object: 'X' } }], + ['data-only', { data: { provider: 'object', object: 'X' } }], + ['objectName-only', { objectName: 'Y' }], + ['empty-objectName', { objectName: '', data: { provider: 'object', object: 'X' } }], + ['api-provider', { objectName: 'Y', data: { provider: 'api', read: { url: '/x' } } }], + ['api-provider-no-name', { data: { provider: 'api', read: { url: '/x' } } }], + ['value-provider', { objectName: 'Y', data: { provider: 'value', items: [1] } }], + ['staticData+objectName', { objectName: 'Y', staticData: [1] }], + ['staticData-only', { staticData: [1] }], + ['array-shorthand', { objectName: 'Y', data: [1, 2] }], + ['data-object-empty-string', { objectName: 'Y', data: { provider: 'object', object: '' } }], + ['empty-objectName-only', { objectName: '' }], + ['nothing-bound', {}], +]; + +describe('resolveRecordSourceObjectName — behaviour neutrality (objectui#7627)', () => { + for (const [name, schema] of CONTRACT_VALID) { + for (const site of SITES) { + it(`${site.id} is unchanged for "${name}"`, () => { + const cfg = getDataConfig(schema); + expect(site.after(schema, cfg)).toEqual(site.before(schema, cfg)); + }); + } + } +}); + +describe('resolveRecordSourceObjectName — the ladder it carries (objectui#6939)', () => { + // Bound rather than inlined: callers hand this reader a whole `ViewData` + // (whose `value` member carries `items`), never a fresh literal narrowed to + // the two keys the reader reads. + const valueConfig: Cfg = { provider: 'value', items: [] }; + + it('reads the resolved record source FIRST when it names an object', () => { + expect( + resolveRecordSourceObjectName( + { objectName: 'accounts' }, + { provider: 'object', object: 'contacts' }, + ), + ).toBe('contacts'); + }); + + it('falls back to `objectName` when the resolved source names no object', () => { + expect( + resolveRecordSourceObjectName({ objectName: 'accounts' }, valueConfig), + ).toBe('accounts'); + expect(resolveRecordSourceObjectName({ objectName: 'accounts' }, { provider: 'api' })).toBe( + 'accounts', + ); + expect(resolveRecordSourceObjectName({ objectName: 'accounts' }, null)).toBe('accounts'); + }); + + it('resolves undefined when nothing names an object', () => { + expect(resolveRecordSourceObjectName({}, null)).toBeUndefined(); + expect(resolveRecordSourceObjectName({}, valueConfig)).toBeUndefined(); + }); + + it('passes an empty `object` through — `ViewDataSchema` declares `z.string()`, not a non-empty one, so coercing it here would invent a rung', () => { + expect( + resolveRecordSourceObjectName({ objectName: 'accounts' }, { provider: 'object', object: '' }), + ).toBe(''); + }); + + it('adds NO lenient rung for an off-contract `{ provider: "object" }` with no `object` (AGENTS.md #0.1)', () => { + expect( + resolveRecordSourceObjectName({ objectName: 'accounts' }, { provider: 'object' }), + ).toBeUndefined(); + }); + + it('is not the `normalizeListViewSchema` gap-fill: an `objectName` present alongside a data block does NOT win here', () => { + // Ruling B (#7628) governs how `objectName` is POPULATED when absent; this + // reader governs which object a block RESOLVES. Merging them would override + // one standing ruling or the other — the whole point of objectui#7627. + expect( + resolveRecordSourceObjectName( + { objectName: 'ruling_b_value' }, + { provider: 'object', object: 'resolved_source' }, + ), + ).toBe('resolved_source'); + }); +}); diff --git a/packages/core/src/utils/record-source.ts b/packages/core/src/utils/record-source.ts new file mode 100644 index 0000000000..3df99dd38b --- /dev/null +++ b/packages/core/src/utils/record-source.ts @@ -0,0 +1,76 @@ +/** + * ObjectUI — the shared record-source object-name reader + * 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. + */ + +/** + * The object a view block is bound to, resolved ONCE for the whole renderer + * (objectui#7627). + * + * ## The question this answers, and the one it does not + * + * There are TWO separately-ruled precedence questions about `objectName`, and + * they only look like one question when a single reader is asked to answer + * both: + * + * 1. **Which object does this block RESOLVE, at render time, when it carries + * more than one binding?** — the published three-rung record-source ladder + * (`data`, then `staticData`, then `objectName`), declared on both faces of + * the contract (`ObjectMapSchema.objectName` / `ObjectGanttSchema.objectName` + * in `@object-ui/types`, and the `.describe` on their zod twins: + * *"objectName — the THIRD record source `getDataConfig` resolves, after + * `data` and `staticData`"*), ruled objectui#6939 (2026-09-02) and pinned by + * `objectql-record-source-refinement-6939.test.ts`. **That is this + * function.** + * 2. **How does `objectName` get POPULATED when it is absent?** — the + * authoring-time gap-fill in `normalizeListViewSchema` (objectui#7477, + * ruling B of PR #7628), where an `objectName` already on the schema WINS + * and the `data` block only fills a gap: *"it can never re-point a binding + * that already resolves."* + * + * The two are NOT merged and neither is re-pointed at the other. Merging them + * would override a standing maintainer ruling in whichever direction the merged + * reader happened to pick: at the sites below the binding that already resolves + * IS `data.object`, so ruling B's own words argue for keeping rung 1 as it is. + * + * ## Why `staticData` does not appear here + * + * The ladder's second rung wraps inline rows as `{ provider: 'value', items }`, + * which names no object at all. So for the object-NAME question the three-rung + * ladder reduces to two rungs — the resolved config's object when it names one, + * else the schema's own `objectName`, which is what a `value`/`api`-backed block + * still needs for metadata reads, i18n field labels and permission verdicts. + * Callers pass the ALREADY-RESOLVED config (their `getDataConfig(schema)` + * output), so rung ordering is settled before this function is reached. + * + * ## No lenient rung was added (AGENTS.md #0.1) + * + * `ViewDataSchema`'s `object` provider is a `strictObject` carrying exactly + * `{ provider, object }` with `object` REQUIRED, so `{ provider: 'object' }` + * without an `object` is off-contract. This reader does not coerce that shape + * back to `objectName`; the two call sites that used to (`ObjectGrid`'s + * `'object' in dataConfig` test and `ObjectTree`'s header `?? schema.objectName` + * tail) keep their own tail at the site, so the collapse changes nothing they + * resolve today while the shared rung stays contract-strict. + * + * @param schema - The block's schema; only `objectName` is read. + * @param dataConfig - The RESOLVED data config — the caller's own + * `getDataConfig(schema)` output, `null` when nothing is bound. + * @returns The bound object's name, or `undefined` when neither the resolved + * config nor the schema names one. + * + * @example + * ```ts + * const dataConfig = useMemo(() => getDataConfig(schema), [schema]); + * const objectName = resolveRecordSourceObjectName(schema, dataConfig); + * ``` + */ +export function resolveRecordSourceObjectName( + schema: { objectName?: string } | null | undefined, + dataConfig: { provider?: string; object?: string } | null | undefined, +): string | undefined { + return dataConfig?.provider === 'object' ? dataConfig.object : schema?.objectName; +} diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 73eb0908aa..e776037371 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -61,6 +61,7 @@ import { convertSortToQueryParams, getRecordDisplayName, createFieldColorResolver, + resolveRecordSourceObjectName, } from '@object-ui/core'; export interface CalendarSchema { @@ -305,8 +306,7 @@ export const ObjectCalendar: React.FC = ({ // different object. Comparing it during render means switching objects closes // the gate in the same commit that changes it, not one commit later, so no // query can carry the previous object's expand set. - const schemaObjectName = - dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName; + const schemaObjectName = resolveRecordSourceObjectName(schema, dataConfig); const schemaKey = schemaObjectName ?? ''; /** * Has the object schema for THIS object finished resolving? Note what this is @@ -966,7 +966,7 @@ export const ObjectCalendar: React.FC = ({ {navigation.isOverlay && navigation.isOpen && navigation.selectedRecord && (() => { - const objectName = dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName; + const objectName = resolveRecordSourceObjectName(schema, dataConfig); const rec = navigation.selectedRecord as Record; const recordId = rec.id ?? rec._id; if (!objectName || recordId == null) return null; diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 165afb79bb..5c250c2eb6 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -61,6 +61,7 @@ import { getRecordDisplayName, resolveDataSource, createFieldColorResolver, + resolveRecordSourceObjectName, } from '@object-ui/core'; import { getSemanticColorName, @@ -657,8 +658,7 @@ export const ObjectGantt: React.FC = ({ // Unified resource name for find/update/delete. For 'object' it's the bound // object; for 'api' the adapter ignores it (the URL carries the endpoint), // so an empty string is fine there. - const resource = - dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName ?? ''; + const resource = resolveRecordSourceObjectName(schema, dataConfig) ?? ''; /** * The object schema, and whether the read for THIS object has SETTLED — @@ -1368,6 +1368,15 @@ export const ObjectGantt: React.FC = ({ // `saveLayout` covers the quick-filter chips too: GanttView persists its own // snapshot under persistLayoutKey and fires onLayoutChange; the chips live up // here, so they get a sibling localStorage key and restore on mount. + // + // ⛔ This line does NOT delegate to `resolveRecordSourceObjectName`, and its + // inverted order relative to `resource` above is not the drift objectui#7627 + // collapsed: the two were never answering the same question. What this + // resolves is a localStorage KEY (`gantt-layout::filters`), not a record + // source — re-pointing it silently orphans every saved layout and filter-chip + // set of any view carrying BOTH bindings. A storage-key migration is a + // separate, user-visible change, so the ruling on objectui#7627 excluded this + // site from the collapse and left the precedence exactly as it is. const persistLayoutKey = schema.persistLayout === false ? undefined diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index dcf82298e0..c61dbfe25b 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -36,7 +36,7 @@ import { RefreshIndicator, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, isObjectInlineEditable, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, ROW_HEIGHT_TO_DENSITY_MODE } from '@object-ui/core'; +import { resolveConditionalFormatting, leadWithNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, isObjectInlineEditable, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNode, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceObjectName } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react'; import { useRowColor } from './useRowColor'; @@ -1203,9 +1203,12 @@ export const ObjectGrid: React.FC = ({ // Extract stable primitive/reference-stable values from schema for dependency arrays. // This prevents infinite re-render loops when schema is a new object on each render // (e.g. when rendered through SchemaRenderer which creates a fresh evaluatedSchema). - const objectName = dataConfig?.provider === 'object' && dataConfig && 'object' in dataConfig - ? (dataConfig as any).object - : schema.objectName; + // The `?? schema.objectName` tail is NOT the shared rung repeated: it stands + // in for the old `'object' in dataConfig` test, i.e. this site's own coercion + // of the OFF-CONTRACT `data: { provider: 'object' }` that carries no `object` + // (`ViewDataSchema` declares it required). Kept because this name gates the + // permission verdicts below — see the note at `inlineEditable`. + const objectName = resolveRecordSourceObjectName(schema, dataConfig) ?? schema.objectName; // [#3391] Server-resolved effective API operation set for this object // (/me/permissions `apiOperations`). The Export button and handler AND their // gate with this — a missing set (unrestricted object / old backend / no diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 3fb9653f85..373f3d570b 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -35,6 +35,7 @@ import { buildExpandFields, convertSortToQueryParams, getRecordDisplayName, + resolveRecordSourceObjectName, } from '@object-ui/core'; import MapGL, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre'; @@ -683,8 +684,22 @@ export const ObjectMap: React.FC = ({ * optimisation rather than a correctness dependency (objectui#6592). */ const dataProvider = dataConfig?.provider; + // NOT a delegation site for `resolveRecordSourceObjectName` (objectui#7627): + // this is the data config's OWN object, deliberately `undefined` for every + // other provider so an `api`/`value` map's `objectName` changing cannot move + // this dependency. The shared reader's second rung would put `objectName` + // here and re-run both effects on a value they do not read. const dataObjectName = dataConfig?.provider === 'object' ? dataConfig.object : undefined; const dataItems = dataConfig?.provider === 'value' ? dataConfig.items : undefined; + /** + * The object this map is BOUND to — the resolved record source's object when + * it names one, else the schema's own `objectName` (objectui#7627, the + * objectui#6939 ladder). Hoisted to render scope so the metadata effect below + * reads one named value instead of re-deriving the ladder inline; it is a pure + * function of `dataProvider` / `dataObjectName` / `schema.objectName`, all of + * which that effect already depends on, so listing it adds no re-run. + */ + const recordSourceObjectName = resolveRecordSourceObjectName(schema, dataConfig); // Fetch data based on provider useEffect(() => { @@ -760,9 +775,7 @@ export const ObjectMap: React.FC = ({ try { if (!dataSource) return; - const objectName = dataProvider === 'object' - ? dataObjectName - : schema.objectName; + const objectName = recordSourceObjectName; if (!objectName) return; @@ -776,7 +789,7 @@ export const ObjectMap: React.FC = ({ if (!hasInlineData && dataSource) { fetchObjectSchema(); } - }, [schema.objectName, dataSource, hasInlineData, dataProvider, dataObjectName]); + }, [schema.objectName, dataSource, hasInlineData, dataProvider, dataObjectName, recordSourceObjectName]); // Transform data to map markers const { markers, invalidCount } = useMemo(() => { diff --git a/packages/plugin-tree/src/ObjectTree.tsx b/packages/plugin-tree/src/ObjectTree.tsx index fdc39dcfac..88ea97119b 100644 --- a/packages/plugin-tree/src/ObjectTree.tsx +++ b/packages/plugin-tree/src/ObjectTree.tsx @@ -38,6 +38,7 @@ import { isExpandableFieldType, getRecordDisplayName, humanizeLabel, + resolveRecordSourceObjectName, } from '@object-ui/core'; import { ChevronRight, ChevronDown } from 'lucide-react'; @@ -369,8 +370,7 @@ export const ObjectTree: React.FC = ({ * the `schema` PROP object whose identity a host that rebuilds its schema * each render changes without changing which object is bound. */ - const schemaKey = - (dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName) ?? ''; + const schemaKey = resolveRecordSourceObjectName(schema, dataConfig) ?? ''; /** * The object schema, and whether it has settled FOR `schemaKey` — a single @@ -442,6 +442,11 @@ export const ObjectTree: React.FC = ({ * the component rather than one effect of two. */ const dataProvider = dataConfig?.provider; + // NOT a delegation site for `resolveRecordSourceObjectName` (objectui#7627): + // this is the data config's OWN object, deliberately `undefined` for every + // other provider so an `api`/`value` tree's `objectName` changing cannot move + // this dependency. The shared reader's second rung would put `objectName` + // here and re-run the record fetch on a value it does not read. const dataObjectName = dataConfig?.provider === 'object' ? dataConfig.object : undefined; const dataItems = dataConfig?.provider === 'value' ? dataConfig.items : undefined; @@ -563,8 +568,12 @@ export const ObjectTree: React.FC = ({ // Column labels: i18n convention key (`objects.{obj}.fields.{field}.label`) // first, then the object schema's authored label, then a humanized field key. const i18n = useSafeFieldLabel(); + // The `?? schema.objectName` tail is NOT the shared rung repeated: it is this + // site's own coercion of the OFF-CONTRACT `data: { provider: 'object' }` that + // carries no `object` (`ViewDataSchema` declares it required), kept so the + // collapse changes nothing this label resolves today. const headerObjectName: string | undefined = - (dataConfig?.provider === 'object' ? (dataConfig as any).object : undefined) ?? schema.objectName; + resolveRecordSourceObjectName(schema, dataConfig) ?? schema.objectName; const fieldLabel = (field: string): string => { const def = objectSchema?.fields?.[field]; const fallback = diff --git a/packages/react/src/hooks/useSettledSchema.ts b/packages/react/src/hooks/useSettledSchema.ts index 1d89c21a1f..bb550f98d0 100644 --- a/packages/react/src/hooks/useSettledSchema.ts +++ b/packages/react/src/hooks/useSettledSchema.ts @@ -52,10 +52,10 @@ export interface SettledSchema { * half as a shared, published hook; leave GATE PLACEMENT — deciding which * effect branch actually waits on `ready` — to each component, because that * part is genuinely component-private (`ObjectCalendar` gates only its - * `object`-provider branch and keys on `dataConfig.object ?? schema.objectName` - * rather than `schema.objectName`, because an inline `value` data set issues - * no metadata read at all — a whole-effect gate would hold its query open on - * a resolution nothing was ever going to produce). + * `object`-provider branch and keys on the object the block RESOLVES rather + * than on `schema.objectName`, because an inline `value` data set issues no + * metadata read at all — a whole-effect gate would hold its query open on a + * resolution nothing was ever going to produce). * * `ready` is DERIVED at render time — `resolution !== null && resolution.key * === key` — from a SINGLE piece of state, `{ key, def } | null`, rather than @@ -96,10 +96,13 @@ export interface SettledSchema { * is absent, which is the exact "no source to read from" outcome that case * needs. * - * @param key - The identity of the object THIS render is asking about (e.g. - * `dataConfig.object ?? schema.objectName ?? ''`). Computing the right key - * is the caller's job — it is the component-private half of the original - * hand copies, not something this hook can infer. + * @param key - The identity of the object THIS render is asking about — for a + * record-bound view, `resolveRecordSourceObjectName(schema, dataConfig) ?? ''` + * from `@object-ui/core`. ⛔ Do NOT re-spell that ladder inline here: six view + * plugins each carried their own copy and had drifted, which is the whole of + * objectui#7627. Choosing the key is still the caller's job — it is the + * component-private half of the original hand copies, not something this hook + * can infer — but the ladder behind it is published and shared. * @param dataSource - The data source to read the definition from. Pass * `undefined`/`null` for a render that should settle immediately with no * definition (no source, or a provider that needs none) rather than adding @@ -108,12 +111,15 @@ export interface SettledSchema { * * @example * ```tsx - * const schemaKey = dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName; + * const schemaKey = resolveRecordSourceObjectName(schema, dataConfig); // @object-ui/core * const { ready: objectSchemaReady, def: objectSchema } = * useSettledSchema(schemaKey ?? '', hasInlineData ? undefined : dataSource); * * useEffect(() => { - * if (dataConfig?.provider === 'object' && !objectSchemaReady) return; // gate placement stays local + * // A PROVIDER test, not an object-name read — it asks "is there metadata to + * // wait for at all", which the shared reader deliberately does not answer. + * // Gate placement stays local (objectui#6482); the ladder does not. + * if (dataConfig?.provider === 'object' && !objectSchemaReady) return; * // ...issue the record query, `buildExpandFields(objectSchema?.fields)` * }, [objectSchemaReady, objectSchema, /* ... *\/]); * ``` From 6e640f4ff64c199219cf6fc8178fb73eeba2f25b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 12:47:52 +0000 Subject: [PATCH 2/2] test(core): pin the two off-contract tails the shared reader deliberately omits `ViewDataSchema` declares the `object` provider's `object` REQUIRED, so `data: { provider: 'object' }` without one is off-contract -- but `ObjectGrid` (via its old `'object' in dataConfig` test) and `ObjectTree`'s header label coerced that shape back to `schema.objectName`, and ObjectGrid gates permission verdicts with the result. The shared reader does not carry that coercion (AGENTS.md #0.1); those two sites keep it as their own tail. Nothing pinned the tails, so a later "redundant `??`" cleanup would have deleted them silently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- ...rd-source.behaviourNeutrality-7627.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/core/src/utils/__tests__/record-source.behaviourNeutrality-7627.test.ts b/packages/core/src/utils/__tests__/record-source.behaviourNeutrality-7627.test.ts index 1e2fb2878d..d18ba3ef8f 100644 --- a/packages/core/src/utils/__tests__/record-source.behaviourNeutrality-7627.test.ts +++ b/packages/core/src/utils/__tests__/record-source.behaviourNeutrality-7627.test.ts @@ -113,6 +113,44 @@ describe('resolveRecordSourceObjectName — behaviour neutrality (objectui#7627) } }); +/** + * The OFF-CONTRACT fork. `ViewDataSchema`'s `object` provider declares `object` + * REQUIRED, so `data: { provider: 'object' }` without one cannot be published — + * but two sites used to coerce it back to `objectName` anyway, and one of them + * (`ObjectGrid`) gates permission verdicts with the result. The shared reader + * deliberately does NOT carry that coercion (AGENTS.md #0.1); the two sites keep + * it as their own tail. These cases pin the tails: delete one and this goes red, + * which is the only thing standing between them and a future "redundant `??`" + * cleanup. + */ +const OFF_CONTRACT_TAIL_SITES = ['ObjectTree:567 headerObjectName', 'ObjectGrid:1206 objectName']; + +describe('the off-contract `{ provider: "object" }` tail (objectui#7627)', () => { + const offContract: Cfg = { provider: 'object' }; + + it('is not answered by the shared reader — no lenient rung was added', () => { + expect(resolveRecordSourceObjectName({ objectName: 'accounts' }, offContract)).toBeUndefined(); + }); + + for (const id of OFF_CONTRACT_TAIL_SITES) { + const site = SITES.find((x) => x.id === id)!; + it(`${id} keeps its own tail, so the shape still resolves to \`objectName\``, () => { + const schema = { objectName: 'accounts' }; + expect(site.before(schema, offContract)).toBe('accounts'); + expect(site.after(schema, offContract)).toBe('accounts'); + }); + } + + it('the sites that never carried the tail still resolve nothing, exactly as before', () => { + const schema = { objectName: 'accounts' }; + for (const id of ['ObjectCalendar:309 schemaObjectName', 'ObjectMap:764 metadata objectName']) { + const site = SITES.find((x) => x.id === id)!; + expect(site.after(schema, offContract)).toEqual(site.before(schema, offContract)); + expect(site.after(schema, offContract)).toBeUndefined(); + } + }); +}); + describe('resolveRecordSourceObjectName — the ladder it carries (objectui#6939)', () => { // Bound rather than inlined: callers hand this reader a whole `ViewData` // (whose `value` member carries `items`), never a fresh literal narrowed to