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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7206-dead-context-tail-comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
---

Comment-only fix, no release: thirteen comments across ten files described the
tail of `dependentValues ?? ctx.formValues ?? ctx.data ?? {}` as a live channel
a host could supply. It is not one, and never has been.

`SchemaRendererContextType` (`packages/react/src/context/SchemaRendererContext.tsx`)
declares exactly `dataSource`, `debug`, `debugFlags` and `apiFetch`, and
`SchemaRendererProvider` accepts no other prop. There is no `data` member and no
`formValues` member, so that tail is **unconditionally empty in production** —
unsettable rather than merely unset, because no host can populate a member the
type does not declare. `dependentValues` is today the only channel that can
carry a record.

The corrected comments now state what is measured. The strongest of them claimed
the fall-through reached "the OUTER page's record"; another credited
`dependentValues` with a context fallback that only `dataSource` actually has;
a third framed the chain as three suppliable links when only the first has ever
been suppliable. Two of the thirteen were written the same day this was
measured, so they were wrong when written rather than gone stale.

No runtime, type or test behaviour changes: the reads themselves are left
exactly as they are, and the diff contains zero non-comment lines. Whether the
channel should be made real or retired is the open question on objectui#7206 and
is deliberately not answered here.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
* (`current_user`), the role-gating case ADR-0058 opens — plus, in the last two
* tests, what a record-relative predicate does on each side of that prop:
* supplied (the dialog's values narrow the list) and absent (the evaluator's
* documented fallback chain still reaches `SchemaRendererContext`).
* `?? ctx.formValues` link, which this file can reach ONLY by casting a member
* onto the context that `SchemaRendererContextType` does not declare — see the
* note on `records` below, and objectui#7206).
*/
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
Expand Down Expand Up @@ -80,9 +82,16 @@ function renderInheritedSelect(
/**
* The two record channels `useCascadingOptions` reads, in its own precedence
* order. `dependentValues` is what a HOST supplies (the dialog now supplies
* its in-progress param values on it — objectui#3765); `ctxFormValues` is the
* `SchemaRendererContext` fallback the hook drops to when no host supplied
* one. Both default to absent, which is the "no record at all" case.
* its in-progress param values on it — objectui#3765); `ctxFormValues` drives
* the hook's `?? ctx.formValues` link.
*
* ⚠️ That second link is NOT reachable in production, and this used to call it
* "the `SchemaRendererContext` fallback the hook drops to".
* `SchemaRendererContextType` declares exactly `dataSource` / `debug` /
* `debugFlags` / `apiFetch`, so the only way to exercise it is the cast the
* provider below performs (objectui#7206). Both default to absent, which is
* the "no record at all" case — and the PRODUCTION case whenever no host
* passes `dependentValues`.
*/
records?: { dependentValues?: Record<string, unknown>; ctxFormValues?: Record<string, unknown> },
) {
Expand Down Expand Up @@ -199,12 +208,18 @@ describe('field-inherited option predicates reach the dialog control (objectui#3

it('falls back to the context record when the host supplies none, and fails OPEN when neither exists', () => {
// The evaluator was NOT touched by objectui#3765 — the ruling was "supply
// the record, do not change the reader" — so its documented chain
// (`dependentValues ?? ctx.formValues ?? ctx.data ?? {}`) must still work
// from the second link down for every widget rendered outside a dialog.
// Pinned here because the dialog is now the loudest producer of the FIRST
// link, and a wiring change that quietly bypassed the rest of the chain
// would look identical from the dialog's side.
// the record, do not change the reader" — so its chain
// (`dependentValues ?? ctx.formValues ?? ctx.data ?? {}`) still consults the
// second link. ⚠️ This used to say that link "must still work … for every
// widget rendered outside a dialog". It cannot:
// `SchemaRendererContextType` declares exactly `dataSource` / `debug` /
// `debugFlags` / `apiFetch`, so outside a dialog that tail is
// unconditionally `{}`, and this case is reachable only through the cast the
// helper performs (objectui#7206). What is pinned here is the READER's
// precedence — that the second link is still consulted at all — not a route
// any host can take today. Pinned because the dialog is the loudest producer
// of the FIRST link, and a wiring change that quietly bypassed the rest of
// the chain would look identical from the dialog's side.
renderInheritedSelect(PROVINCE, ['admin'], undefined, { ctxFormValues: { country: 'us' } });
expect(screen.getByTestId('select-empty-tier')).toBeInTheDocument();

Expand Down
28 changes: 19 additions & 9 deletions packages/app-shell/src/views/ActionParamDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,22 +327,32 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp
// predicates are resolved against (objectui#3765, maintainer ruling
// 2026-08-11, Option B: "the dialog is a small form"). Until this
// prop existed the dialog passed nothing, so `useCascadingOptions`
// fell through to `SchemaRendererContext`'s `formValues` / `data` —
// the OUTER page's record — and a `visibleWhen` written against a
// resolved the EMPTY record — and a `visibleWhen` written against a
// sibling PARAM could never see the value the user had just picked
// in this same dialog. The evaluator is untouched: it already reads
// `dependentValues ?? formValues ?? data`; this is the supply half
// that was missing.
//
// ⚠️ This used to say the fall-through reached "`SchemaRendererContext`'s
// `formValues` / `data` — the OUTER page's record". It did not, and
// no host could have made it: `SchemaRendererContextType` declares
// exactly `dataSource` / `debug` / `debugFlags` / `apiFetch`, so
// that tail is unconditionally `{}` — unsettable, not merely unset
// (objectui#7206). The fall-through reached `{}`, which is why the
// predicate came back UNRESOLVABLE rather than resolved against
// some outer record.
//
// Ruled cost, recorded rather than worked around: because a
// supplied record wins that chain outright, a predicate naming a
// ROW field the dialog has no param for (`record.owner_id`) no
// longer resolves against the host page here. It becomes
// unresolvable, which `resolveVisibleOptions` fails OPEN — the
// option is offered, never wrongly hidden. Merging the two records
// (`{ ...row, ...values }`) was option C on the card and was NOT
// ruled: it invents a third scope dialect that would have to be
// written into the contract first.
// ROW field the dialog has no param for (`record.owner_id`) does not
// resolve against the host page here. It becomes unresolvable, which
// `resolveVisibleOptions` fails OPEN — the option is offered, never
// wrongly hidden. ⚠️ Measured, that cost is not a loss: the same
// predicate was ALREADY unresolvable before this prop existed,
// because the chain's tail could not reach a host record then
// either. Merging the two records (`{ ...row, ...values }`) was
// option C on the card and was NOT ruled: it invents a third scope
// dialect that would have to be written into the contract first.
const cascadeProps = CASCADE_OPTION_WIDGET_TYPES.has(field.type)
? { dependentValues: values }
: {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,17 @@ describe('form renderer — data-source wiring covers the core reference family'
});

it('threads them to `user` — the member the private copy never had', () => {
// Before objectui#4790 a `user` field received NONE of the three. Two of
// them have a `SchemaRendererContext` fallback inside the widget, so the
// person picker limped along; `dependsOnLabels` has no fallback at all, so
// a dependency-gated user picker interpolated the raw API name into its
// "select … first" hint — the leak objectstack#5407 closed for lookups and
// left open here. `user` is also named in the widget contract's own
// Before objectui#4790 a `user` field received NONE of the three. ONE of
// them — `dataSource` — has a `SchemaRendererContext` fallback inside the
// widget, so the person picker limped along; `dependentValues` and
// `dependsOnLabels` have none, so a dependency-gated user picker was left
// unscoped AND interpolated the raw API name into its "select … first" hint
// — the leak objectstack#5407 closed for lookups and left open here.
//
// ⚠️ This used to credit two of the three with a context fallback. The
// widgets spell one for `dependentValues` (`?? ctx.formValues ?? ctx.data`),
// but `SchemaRendererContextType` declares exactly `dataSource` / `debug` /
// `debugFlags` / `apiFetch`, so it is unconditionally empty (objectui#7206). `user` is also named in the widget contract's own
// `dataSource` doc (`fields/src/widgets/types.ts`) as a type this renderer
// injects for, so the omission contradicted the published contract too.
expect(EXPANDABLE_FIELD_TYPES.has('user')).toBe(true);
Expand Down
15 changes: 11 additions & 4 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,17 @@ const DATA_SOURCE_ONLY_WIDGET_TYPES = new Set([
* claimed the two "mirror" each other; by then they had drifted apart in both
* directions — `user` only in core, the three picker names only here — and the
* gap was live, not theoretical: a `user` field got NONE of the three props.
* `dataSource` and `dependentValues` each have a `SchemaRendererContext`
* fallback inside the widget so the picker limped along, but `dependsOnLabels`
* has none, so a dependent user picker interpolated raw API names into its
* "select … first" hint — the very leak objectstack#5407 fixed for lookups.
* `dataSource` has a `SchemaRendererContext` fallback inside the widget, so the
* picker limped along; `dependentValues` and `dependsOnLabels` have none, so a
* dependent user picker was left unscoped AND interpolated raw API names into
* its "select … first" hint — the very leak objectstack#5407 fixed for lookups.
*
* ⚠️ This used to credit `dependentValues` with a context fallback too. The
* widgets spell one (`?? ctx.formValues ?? ctx.data`), but
* `SchemaRendererContextType` declares exactly `dataSource` / `debug` /
* `debugFlags` / `apiFetch`, so it is unconditionally empty — unsettable, not
* merely unset (objectui#7206). Of the three props above, only `dataSource` is
* really served by the context.
* The widget contract has always named `user` among the types this renderer
* injects `dataSource` for (`fields/src/widgets/types.ts`).
*
Expand Down
26 changes: 23 additions & 3 deletions packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,8 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
[dependsOn, dependsOnLabelsProp, t],
);

// Resolve dependent field values from explicit prop or SchemaRendererContext.data
// Resolve dependent field values from the explicit prop. See the resolver
// below for why the context leg of that chain cannot fire (objectui#7206).
const dependentValuesProp = props.dependentValues;

// Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
Expand All @@ -345,8 +346,27 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro
const dataSource: DataSource | null =
(props.dataSource as DataSource | null | undefined) ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;

/** Resolve dependent values from the explicit prop (preferred), the form-data
* context provided by @object-ui/react, or finally `ctx.data` (record scope). */
/** Resolve dependent values from the explicit prop — today the ONLY channel
* that can carry a record.
*
* ⚠️ This comment used to call `ctx.data` the "record scope" channel and
* `ctx.formValues` a "form-data context provided by @object-ui/react".
* Neither member exists. `SchemaRendererContextType`
* (`@object-ui/react`, `context/SchemaRendererContext.tsx`) declares exactly
* `dataSource`, `debug`, `debugFlags` and `apiFetch`, and
* `SchemaRendererProvider` accepts no other prop — so the
* `?? ctx?.formValues ?? ctx?.data` tail below is UNCONDITIONALLY `{}` in
* production. Unsettable, not merely unset: no host can populate a member
* the type does not declare. A widget reached without `dependentValues`
* therefore resolves `{}`, which for a `dependsOn` lookup renders a
* permanently gated picker — the shared root of objectui#7165 (the grid's
* inline column) and objectui#7190 (the detail page), both of which were
* first read as independent host bugs because this comment said a host
* could supply the record through the context.
*
* ⛔ The tail is left exactly as it is. Whether the channel should be made
* real or retired is OPEN on objectui#7206 and is not decided here; do not
* read this note as either outcome. */
const resolvedDependentValues: Record<string, any> = useMemo(() => {
if (dependentValuesProp) return dependentValuesProp;
return (ctx?.formValues ?? ctx?.data ?? {}) as Record<string, any>;
Expand Down
10 changes: 8 additions & 2 deletions packages/fields/src/widgets/toHostProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ import type { FieldWidgetComponentProps } from './types.js';
* that does it: "explicit prop > field-level > wrapper field >
* SchemaRendererContext > none". Hosts that pass nothing keep reading the
* context, so the grid's inline editor is unaffected.
* - `dependentValues`: the explicit prop wins, then `ctx.formValues`, then
* `ctx.data` (`useCascadingOptions`, and `LookupField`'s own resolver).
* - `dependentValues`: the explicit prop is the ONLY channel that can carry a
* record. Both readers (`useCascadingOptions`, and `LookupField`'s own
* resolver) spell a `?? ctx.formValues ?? ctx.data` tail after it, but
* `SchemaRendererContextType` declares exactly `dataSource` / `debug` /
* `debugFlags` / `apiFetch` — neither member exists, so that tail is
* unconditionally `{}` and no host can change that (objectui#7206). Unlike
* `dataSource` above, DELIVERING this key cannot displace a context value,
* because there has never been one to displace.
* - `dependsOn`: the FIELD METADATA wins over the prop — the one documented
* inversion, stated on the key's own doc comment and implemented as
* `config?.dependsOn ?? dependsOnProp` in all four option widgets.
Expand Down
9 changes: 8 additions & 1 deletion packages/fields/src/widgets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,14 @@ export type FieldWidgetComponentProps<T = any> = {
/**
* Live sibling-field values driving cascading / role-gated options and
* dependent lookups (ADR-0058, #2215/#2284). The form renderer passes the
* in-progress record; widgets fall back to `SchemaRendererContext`.
* in-progress record.
*
* ⚠️ This used to add "widgets fall back to `SchemaRendererContext`". They
* spell such a fallback (`?? ctx.formValues ?? ctx.data`), but
* `SchemaRendererContextType` declares exactly `dataSource` / `debug` /
* `debugFlags` / `apiFetch`, so neither member can be supplied by any host and
* the fallback is unconditionally empty. This prop is today the only channel
* that can carry a record (objectui#7206).
*/
dependentValues?: Record<string, unknown>;
/**
Expand Down
15 changes: 12 additions & 3 deletions packages/fields/src/widgets/useCascadingOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,18 @@ export function useCascadingOptions<T extends OptionLike>(
dependentValues: Record<string, unknown> | undefined,
): CascadingOptionsResult<T> {
// Live form values for cascading options — injected by the form renderer as
// `dependentValues` (same channel dependent lookups use), falling back to the
// record on SchemaRendererContext. `current_user` etc. come from the global
// predicate scope so role/context predicates resolve too.
// `dependentValues` (same channel dependent lookups use). `current_user` etc.
// come from the global predicate scope so role/context predicates resolve too.
//
// ⚠️ This used to say the chain falls back to "the record on
// SchemaRendererContext". There is no such record. `SchemaRendererContextType`
// declares exactly `dataSource` / `debug` / `debugFlags` / `apiFetch`, so the
// `?? ctx?.formValues ?? ctx?.data` tail below is unconditionally `{}` in
// production — unsettable, not merely unset, because no host can populate a
// member the type does not declare. `dependentValues` is today the only
// channel that can carry a record; reached without it, a `dependsOn` option
// list stays gated. The reads are left in place: objectui#7206 owns the open
// question of whether that channel becomes real or is retired.
const ctx = useContext(SchemaRendererContext) as any;
const record = useMemo<Record<string, unknown>>(() => {
return (dependentValues ?? ctx?.formValues ?? ctx?.data ?? {}) as Record<string, unknown>;
Expand Down
7 changes: 6 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3752,7 +3752,12 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// The record a dependent widget scopes itself by. `LookupField`
// resolves `dependentValues ?? ctx.formValues ?? ctx.data ?? {}`
// and this grid supplied NONE of the three, so the resolved record
// was `{}` for every row. A column declaring `dependsOn` therefore
// was `{}` for every row. ⚠️ Only the FIRST link was ever
// suppliable, by this host or any other:
// `SchemaRendererContextType` declares exactly `dataSource` /
// `debug` / `debugFlags` / `apiFetch`, so the tail is
// unconditionally empty repo-wide (objectui#7206). That is why the
// repair has to be this prop and could not have been a provider. A column declaring `dependsOn` therefore
// rendered a permanently gated, disabled trigger ("Select region
// first") even when the row carried the parent value — a field
// that could never be filled, with no diagnostic. PR objectui#2216
Expand Down
Loading
Loading