Skip to content
Draft
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
40 changes: 40 additions & 0 deletions .changeset/9108-props-bag-node-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/react': minor
---

`SchemaRenderer`'s node gates now read the legacy `props` config bag as a last
resort, so a visibility or enablement predicate authored under that spelling is
finally honoured (objectui#9108).

**What was wrong.** A node may spell its config bag `properties` (the spec
spelling) or `props` (the annotated legacy alias). The hoist in the evaluation
memo copies `properties.*` onto the node; nothing copies `props.*`. Both node
gates read the post-hoist node, so a predicate that arrived under the alias was
never one of the keys either gate could see. Measured at node level, four rows:
`props: { visible: false }` rendered and `props: { hidden: true }` rendered,
while `properties: { visible: false }` and `properties: { hidden: true }` each
hid correctly. Fail-**open** and silent by construction — a gate that never bit
renders exactly like a gate that said yes — so it could not be found by looking
at a page, only by counting.

**BREAKING (scored `minor` per this repo's version-alignment convention)** — deliberately,
and narrowly. A node whose author wrote a falsy
visibility predicate (or a truthy `disabled`) inside a `props` bag rendered
before and is now gated. That is the whole point of the repair, and it is the
only verdict that moves: the alias is consulted **only** where the post-hoist
node holds nothing, so a key the node itself declares — or one the canonical
bag hoisted onto it — still decides exactly as before. Marked `minor` rather
than `patch` for that reason.

**Precedence is unchanged in both directions.** `properties` still wins on both
channels (objectui#5123, maintainer ruling 2026-08-18); the subtraction that
enforces it keeps its single declaration and is reused here rather than
restated. Nothing is hoisted: `props` is still not copied onto the node,
`schema.<KEY>` is still undefined for a renderer declared as `({ schema })`,
and the dropped-props-bag dev warning still says exactly what it said.

**Migration.** A producer census over every tracked document in this repository
found **zero** authoring a predicate key inside a `props` bag, so nothing here
changes. If your own metadata authors one, it was silently doing nothing before
and now takes effect: check that the predicate says what you meant, or move the
key to `properties`, which is the spec spelling and has always worked.
113 changes: 93 additions & 20 deletions packages/react/src/SchemaRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -538,10 +538,20 @@ const visibilityGateKind = (key: VisibilityChainKey): PredicateGateKind =>
function winningVisibilityKey(node: Record<string, unknown>): VisibilityChainKey | undefined {
const propertiesBag = node.properties;
const hasPropertiesBag = isConfigBag(propertiesBag);
const effective = (key: string): unknown =>
hasPropertiesBag && Object.prototype.hasOwnProperty.call(propertiesBag, key)
? (propertiesBag as Record<string, unknown>)[key]
: node[key];
// The legacy `props` alias, as the LAST resort only — the same third leg the
// node gates grew at objectui#9108, read from the same declaration
// ({@link propsWithoutCanonicalKeys}) so the canonical bag still wins here
// exactly as it wins there. Without this leg the diagnostic and `shouldHide`
// would disagree about which key decides whenever a predicate arrived under
// the alias, and the agreement stated above is what this function is for.
const aliasBag = propsWithoutCanonicalKeys(node.props, propertiesBag);
const effective = (key: string): unknown => {
if (hasPropertiesBag && Object.prototype.hasOwnProperty.call(propertiesBag, key)) {
return (propertiesBag as Record<string, unknown>)[key];
}
const own = node[key];
return own !== undefined ? own : aliasBag[key];
};
for (const key of VISIBILITY_SHOW_KEYS) {
if (effective(key) !== undefined) return key;
}
Expand Down Expand Up @@ -1382,6 +1392,61 @@ export const SchemaRenderer: ForwardRefExoticComponent<
newSchema.props = newProps;
}

/**
* What a PREDICATE-CHAIN key resolves to for the two node gates below, with
* the legacy `props` alias as the LAST resort (objectui#9108).
*
* ## The gap this closes
*
* A node may spell its config bag `properties` (the spec spelling) or
* `props` (the annotated legacy alias). The hoist above copies
* `properties.*` onto the node; NOTHING copies `props.*`. Both gates below
* read the post-hoist node, so a predicate that arrived under the alias was
* never one of the keys either gate could see. Measured at node level on
* `1e0e46af9`, four rows, both spellings and both polarities:
* `props: { visible: false }` RENDERED and `props: { hidden: true }`
* RENDERED, while `properties: { visible: false }` and
* `properties: { hidden: true }` each hid correctly. Fail-OPEN and silent
* by construction: a gate that never bit renders exactly like a gate that
* said yes, so nobody can find it by looking at a page.
*
* ## Why the alias is HONOURED here rather than refused
*
* The cheaper-looking repair — make a predicate under `props` refuse
* loudly — would overturn the maintainer ruling of 2026-08-18 recorded on
* {@link propsWithoutCanonicalKeys}, whose scope paragraph states that "a
* key only `props` declares is untouched (the alias keeps working)". A
* predicate authored only under `props` is exactly such a key. The producer
* census run for objectui#9108 swept every tracked document and found ZERO
* authoring a predicate key inside a `props` bag, so no document in this
* repository changes verdict in either direction; the standing ruling is
* what picks the arm, not the count.
*
* ## Why this is NOT a second hoist
*
* Nothing is written onto the node. The bag is READ, as a last resort, so
* every other statement this tree makes about the alias stays true: `props`
* is still not hoisted, `schema.<KEY>` is still undefined for a renderer
* declared as `({ schema })`, and the objectui#6708 dropped-bag warning
* still says exactly what it said. A renderer sees no key it did not see
* before.
*
* ## Precedence is unchanged in BOTH directions, and declared once
*
* {@link propsWithoutCanonicalKeys} already subtracts every key the
* canonical bag declares, so `properties` still wins (objectui#5123) and
* that rule keeps its single declaration. The reader consults the alias
* only where the post-hoist node holds `undefined`, so a key the node
* itself declares — or one the canonical bag hoisted onto it — still
* decides, unchanged. The chain ORDER below is untouched.
*/
const aliasGateBag = propsWithoutCanonicalKeys(newSchema.props, newSchema.properties);
// Typed as the predicate evaluators' own parameter, which is what every
// caller below hands it — and as wide as the bare `newSchema.<KEY>` read it
// replaces, since `BaseSchema`'s index signature admits anything.
const gateValue = (key: VisibilityChainKey | EnablementNodeGateKey): VisibilityPredicate =>
newSchema[key] !== undefined ? newSchema[key] : aliasGateBag[key];

// Evaluate visibility: visibleWhen / visible / visibleOn / visibility / hidden / hiddenOn
const shouldHide = (() => {
// `visibleWhen` is the single canonical conditional-visibility predicate
Expand All @@ -1403,26 +1468,30 @@ export const SchemaRenderer: ForwardRefExoticComponent<
// the one key the spec tells authors to write was the one key that could
// be silently ignored. A declared node predicate now outranks a hoisted
// renderer prop; when both resolve to "show", both still have to.
if (newSchema.visibleWhen !== undefined) {
return !evaluateVisibilityPredicate(newSchema.visibleWhen, 'visibleWhen');
const visibleWhen = gateValue('visibleWhen');
if (visibleWhen !== undefined) {
return !evaluateVisibilityPredicate(visibleWhen, 'visibleWhen');
}
// `visible` — objectui's own `BaseSchema` tier (`@object-ui/types`), and
// the landing spot of a hoisted `properties.visible`. Kept ABOVE the two
// deprecated aliases: they normalize into `visibleWhen` at parse, so a
// spec-parsed page never reaches them, and re-ranking them would move
// verdicts for raw metadata that objectui#5454 did not rule on.
if (newSchema.visible !== undefined) {
return !evaluateVisibilityPredicate(newSchema.visible, 'visible');
const visible = gateValue('visible');
if (visible !== undefined) {
return !evaluateVisibilityPredicate(visible, 'visible');
}
// @deprecated ADR-0089 → `visibleWhen`. Defensive read for raw /
// un-normalized metadata reaching the renderer.
if (newSchema.visibleOn !== undefined) {
return !evaluateVisibilityPredicate(newSchema.visibleOn, 'visibleOn');
const visibleOn = gateValue('visibleOn');
if (visibleOn !== undefined) {
return !evaluateVisibilityPredicate(visibleOn, 'visibleOn');
}
// @deprecated ADR-0089 → `visibleWhen` (was PageNodeSchema.visibility,
// an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`.
if (newSchema.visibility !== undefined) {
return !evaluateVisibilityPredicate(newSchema.visibility, 'visibility');
const visibility = gateValue('visibility');
if (visibility !== undefined) {
return !evaluateVisibilityPredicate(visibility, 'visibility');
}
// Ask "is a `hidden` gate DECLARED?" — not "is the key present?"
// (objectui#3955). These two legs are the only ones in this chain whose
Expand All @@ -1442,11 +1511,13 @@ export const SchemaRenderer: ForwardRefExoticComponent<
// the RAW value; only the gate in front of it narrowed. Not an
// equivalence, and pinned as a behaviour change: an UNDECLARED `hidden` no
// longer short-circuits, so a declared `hiddenOn` is finally consulted.
if (hasDeclaredPredicate(newSchema.hidden)) {
return evaluateVisibilityPredicate(newSchema.hidden, 'hidden');
const hidden = gateValue('hidden');
if (hasDeclaredPredicate(hidden)) {
return evaluateVisibilityPredicate(hidden, 'hidden');
}
if (hasDeclaredPredicate(newSchema.hiddenOn)) {
return evaluateVisibilityPredicate(newSchema.hiddenOn, 'hiddenOn');
const hiddenOn = gateValue('hiddenOn');
if (hasDeclaredPredicate(hiddenOn)) {
return evaluateVisibilityPredicate(hiddenOn, 'hiddenOn');
}
return false;
})();
Expand Down Expand Up @@ -1495,11 +1566,13 @@ export const SchemaRenderer: ForwardRefExoticComponent<
// earlier, which is what keeps the objectui#3862 empty-shape rows silent
// as well as enabled.
const isDisabled = (() => {
if (hasDeclaredPredicate(newSchema.disabled)) {
return evaluateEnablementPredicate(newSchema.disabled, 'disabled');
const disabled = gateValue('disabled');
if (hasDeclaredPredicate(disabled)) {
return evaluateEnablementPredicate(disabled, 'disabled');
}
if (hasDeclaredPredicate(newSchema.disabledOn)) {
return evaluateEnablementPredicate(newSchema.disabledOn, 'disabledOn');
const disabledOn = gateValue('disabledOn');
if (hasDeclaredPredicate(disabledOn)) {
return evaluateEnablementPredicate(disabledOn, 'disabledOn');
}
return false;
})();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ const Probe = (props: { schema?: { props?: Record<string, unknown> } }) => (
data-testid="probe"
// What the `props`-bag value still IS by the time a renderer reads it.
// `@object-ui/components`' `readProps` merges `{ ...schema.props,
// ...schema.properties }`, so this bag is a real consumer surface even
// though the node gate never consults it (see the `props` group below).
// ...schema.properties }`, so this bag is a real consumer surface — and
// since objectui#9108 the node gate consults it as a last resort too,
// which is a verdict rather than a value and is pinned in its own file.
data-props-visible-kind={
props.schema?.props?.visible && typeof props.schema.props.visible === 'object'
? `envelope:${String((props.schema.props.visible as { dialect?: unknown }).dialect)}`
Expand Down Expand Up @@ -151,14 +152,18 @@ describe('#9100 — a CEL envelope in the config bag reaches the CEL engine', ()
* to: objectui#5123 ruled "one answer per key, whichever channel reads it",
* and `@object-ui/components`' `readProps` merges `{ ...schema.props,
* ...schema.properties }`, so a renderer really can read a predicate from
* this bag. What it CANNOT do is drive the node gate — the hoist copies
* `properties` onto the node and nothing copies `props` — so the assertion
* here is on the value a renderer receives, not on a verdict.
* this bag. The assertion HERE is on the value such a renderer receives, not
* on a verdict — this file is about the envelope surviving the config-bag
* channel, and that is a different question from which bag the gate reads.
*
* ⚠️ That gap is PRE-EXISTING and independent of this card: measured on the
* same tree, a plain `props: { visible: false }` renders and a plain
* `props: { hidden: true }` renders too, while the `properties` spelling of
* either decides correctly. Filed separately; ⛔ not repaired here.
* ⚠️ When this was written the alias could not drive the node gate at all —
* the hoist copies `properties` onto the node and nothing copies `props` —
* and this docblock recorded that gap as PRE-EXISTING and filed separately.
* objectui#9108 closed it: the gate now consults the alias as a last resort,
* with `properties` still winning. ⛔ Still nothing is hoisted, so the value
* this test reads off the bag is unchanged and this assertion is unaffected.
* The four-row verdict table lives in
* `SchemaRenderer.propsBagNodeGate.test.tsx`, not here.
*/
it('props.visible keeps its envelope for the renderer that reads that bag', () => {
mount({ type: 'probe-9100', props: { visible: HOLDS } });
Expand Down
Loading
Loading