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
59 changes: 59 additions & 0 deletions .changeset/6708-props-bag-component-renderer-diagnostic.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
---
'@object-ui/react': patch
---

A `props` config bag on a component-renderer node is now named at render
instead of dropped in silence (objectui#6708).

`SchemaRenderer` HOISTS every `properties.*` value onto the node, so a key
written under `properties` is a real value on `schema.<key>` by the time a
renderer destructures it. `props` — the annotated legacy alias of the same bag
— is NOT hoisted: it is evaluated and then spread as React props on the created
element. A renderer declared as `({ schema })`, which is the normal shape for
the component renderers, therefore never sees it. The `element:*` family is the
exception: its `readProps()` merges `{ ...schema.props, ...schema.properties }`,
so the same spelling is honoured there.

Every gate accepts the `props` spelling — `BaseSchema` is `.passthrough()` with
`[key: string]: any` — and the docs call it a supported alias, so nothing
between the author and the screen said a word. Re-measured on `faac0d935`
through the real `SchemaRenderer` with a probe that records both channels:

| node | React prop `data` | `schema.data` |
|---|---|---|
| `props: { data: "${data.customers}" }` | the evaluated array | absent |
| `properties: { data: "${data.customers}" }` | the evaluated array | the array |

Same key, same value, one envelope apart. The expression is evaluated on both
legs, so this is a dropped value rather than an unevaluated one. Read through a
real `data-table` (objectui#6665's four-leg pin) the same pair renders
`No results found` against the two rows.

The diagnostic's level and dedupe were chosen from a census, which the ruling
made a precondition. Every JSON document, every `json` fence in every
`.md`/`.mdx`, and every TypeScript object literal in the repo was walked for
nodes carrying both `type` and `props`: 39 such nodes, 22 of them on
component-renderer types, and 19 of those 22 are test fixtures exercising this
shape on purpose. The authored, non-test corpus holds 5 — three of which are
deliberate counter-examples in the skills guides. Nothing floods, so the level
is not softened for volume; the dedupe is keyed on the MESSAGE rather than on
the schema object, so a metadata generator emitting one wrong envelope across
many nodes still gets one line while two genuinely different nodes get two.

`console.warn`, matching objectui#6575 and objectui#6665 — the two prior
instances of this exact "you declared something and the renderer dropped it"
shape — rather than the `console.error` its neighbour at this tier uses for a
raw `${...}` placed verbatim in front of a user. Nothing is placed here; a
value is dropped.

No behaviour change, which is the entire reason this arm was chosen. Hoisting
`props` to parity with `properties` was refused at ruling: it would weld the
legacy alias in as a permanent second spelling, against this repo's
alias-retirement direction. Refusing the key at parse stays blocked on the
`.passthrough()` ceiling (objectui#5155 / objectui#6269). What every renderer
receives is pinned byte-for-byte against a reading captured on the tree before
the diagnostic existed. Nothing is added to the published surface either — the
predicate, message builder, prefix constant and test-only reset are
module-internal and are not re-exported from the package entry, matching
objectui#6575's own symbols. The trap stops being silent; it does not stop
being a trap.
48 changes: 43 additions & 5 deletions packages/react/src/SchemaRenderer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,7 @@ import { usePredicateScope } from './hooks/useExpression.js';
import { usePageVariables } from './hooks/usePageVariables.js';
import { resolveKeyedI18nLabel } from './utils/i18n.js';
import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js';
import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js';
import {
reportUnresolvableVisibilityPredicate,
reportAdapterOnlyDataPredicate,
Expand DownExpand Up@@ -1330,6 +1331,44 @@ export const SchemaRenderer: ForwardRefExoticComponent<
);
}

// The legacy `props` alias, narrowed exactly as objectui#5123 ruled: for a
// key BOTH bags declare, `properties` wins here as it already wins in
// `readProps()`, so one key has one answer on both channels.
//
// HOISTED out of the `createElement` call below (objectui#6708) so the
// diagnostic and the spread read the SAME bag. It is the same pure call with
// the same arguments producing the same object in the same spread position —
// nothing about what any renderer receives moves — but it removes the one way
// this diagnostic could go wrong: reporting a set of keys that is not the set
// actually handed to the component.
const outgoingPropsBag = propsWithoutCanonicalKeys(
evaluatedSchema.props,
evaluatedSchema.properties
);

// Dev-build diagnostic (objectui#6708, maintainer ruling 2026-08-29, option
// 2): those keys are spread as React props and never hoisted onto the node,
// so a renderer that reads its config from `schema` — every family except
// `element:*`'s `readProps()` — drops them without a word.
//
// Sited HERE, beside its objectui#4795 neighbour and after the metadata
// destructure, for the same reason: this is the point where what leaves this
// component for the renderer is finally known. Read-only — it reports what
// the line above already computed and changes nothing that is rendered.
//
// The AUTHORED bag comes from `schema`, not `evaluatedSchema`: the evaluation
// memo rebuilds `props` with an object spread, which turns a degenerate
// `props: 'text'` into `{ '0': 't', … }` long before this line. See
// `collectDroppedPropsKeys`.
if (__DEV__) {
reportDroppedPropsBag(
evaluatedSchema.type,
evaluatedSchema.id,
(schema as { props?: unknown } | null | undefined)?.props,
outgoingPropsBag
);
}

// SDUI scoped styling (ADR-0065) — computed in the memo hoisted above the
// early returns; see the doc comment there for why it cannot live here.
const { scopeClass, scopedCss, mergedClassName, schemaForComponent } = scopedStyling;
Expand DownExpand Up@@ -1361,11 +1400,10 @@ export const SchemaRenderer: ForwardRefExoticComponent<
schema: schemaForComponent,
...componentProps, // Spread non-metadata schema properties as props
// The legacy `props` alias still overrides plain top-level keys, but no
// longer overrides the canonical `properties` bag: for a key BOTH bags
// declare, `properties` wins here exactly as it already wins in
// `readProps()`, so one key has one answer on both channels
// (objectui#5123, maintainer ruling 2026-08-18).
...propsWithoutCanonicalKeys(evaluatedSchema.props, evaluatedSchema.properties),
// longer overrides the canonical `properties` bag (objectui#5123,
// maintainer ruling 2026-08-18). Computed above rather than inline, so
// the objectui#6708 diagnostic names this exact bag — see there.
...outgoingPropsBag,
...ariaProps, // Inject ARIA attributes from AriaPropsSchema
...debugAttrs, // Debug-mode data attributes
disabled: __disabled || undefined,
Expand Down
Loading
Loading