diff --git a/.changeset/9108-props-bag-loud-refusal.md b/.changeset/9108-props-bag-loud-refusal.md new file mode 100644 index 0000000000..d06f84a476 --- /dev/null +++ b/.changeset/9108-props-bag-loud-refusal.md @@ -0,0 +1,11 @@ +--- +"@object-ui/react": patch +--- + +**A node-gate predicate parked under `props` is now REFUSED BY NAME on the console, instead of gating nothing in silence (objectui#9108).** + +A node may spell its config bag `properties` (the spec spelling) or `props` (the annotated legacy alias). `SchemaRenderer` hoists `properties.*` onto the node; nothing copies `props.*`, and 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 }` and `props: { hidden: true }` both 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 no user and no screenshot can find it. + +**No verdict moves, and that is the ruled outcome rather than a limitation.** The alias is refused, ⛔ not honoured: nothing is hoisted, `schema.` stays undefined for a renderer declared as `({ schema })`, and every element receives the byte-identical props bag it received before. What changes is that the eight node-gate predicate keys (`visibleWhen` / `visible` / `visibleOn` / `visibility` / `hidden` / `hiddenOn` / `disabled` / `disabledOn`) are named on the console when they are parked there, with the migration that fixes them. The maintainer ruling of 2026-09-13 closed the honour arm (PR objectui#9144) on the ground that honouring the alias would have made *"8 predicate keys work while the rest stayed silently dropped — and partly working is harder to learn from than not working"*. + +The line is `console.error`, in development **and in production** — the same posture objectui#6038 gave the unresolvable-predicate report, because a node gate that has stopped biting in production is exactly the defect that must not be able to sit live and undiscovered. It is rate-limited to one line per distinct authoring bug for the lifetime of the page. One measured carve-out: `disabled` on a bag-reading `element:*` node is honoured by that renderer itself (`disabled={props.disabled || running}`), so it is not refused there. diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 99d226a18d..c367c810a1 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -31,7 +31,7 @@ import { usePageVariables } from './hooks/usePageVariables.js'; import { resolveKeyedI18nLabel } from './utils/i18n.js'; import { isConfigBag } from './utils/configBag.js'; import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js'; -import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js'; +import { reportDroppedPropsBag, reportRefusedPropsPredicate } from './utils/propsBagDiagnostic.js'; import { expressionBindableTextKeysFor } from '@objectstack/spec/ui'; import { reportUnresolvableVisibilityPredicate, @@ -330,6 +330,25 @@ const PREDICATE_CHAIN_KEYS: ReadonlySet = new Set([ ...ENABLEMENT_RENDERER_KEYS, ]); +/** + * Every key a NODE GATE in this file actually consults, as ONE lookup for the + * objectui#9108 refusal below. DERIVED from the same two declarations the gates + * are built from, so a leg added to either chain is refused under `props` by the + * same edit that adds it. + * + * {@link PREDICATE_CHAIN_KEYS} minus {@link ENABLEMENT_RENDERER_KEYS}, and the + * subtraction is the whole reason this is a second derivation rather than a + * reuse: `enabled` is in that union because the config-bag evaluation loops + * flatten it, but NO gate here consults it - the action renderers read it one + * layer down off the schema and negate it. Refusing it here would state, of a + * key this file never asks about, that a gate in this file could not see it. + * Its own `props` drop is objectui#6708's subject and is reported there. + */ +const NODE_GATE_PREDICATE_KEYS: ReadonlySet = new Set([ + ...VISIBILITY_CHAIN_KEYS, + ...ENABLEMENT_NODE_GATE_KEYS, +]); + /** * Is this value the canonical CEL envelope — `{ dialect: 'cel', source }` — * that `@objectstack/spec` normalizes an authored predicate into? @@ -1423,6 +1442,45 @@ export const SchemaRenderer: ForwardRefExoticComponent< newSchema.props = newProps; } + /** + * REFUSE, by name, a node-gate predicate parked under the legacy `props` + * alias (objectui#9108, maintainer ruling 2026-09-13, verbatim 「同意」 on + * the `domain:spec` seat's recommendation). + * + * ## Sited HERE, immediately in front of the two gates + * + * This is the one point where the gates' own input is final: the + * `properties` hoist above has run, both config-bag evaluation loops have + * run, and neither gate has consulted anything yet. It is also the only + * placement that survives its own subject - the late diagnostics near + * `createElement` are downstream of `if (shouldHide) return null`, so a node + * that parks `visible` under `props` while ALSO hiding through the canonical + * spelling would never reach them, and the refusal would go missing on the + * one shape that carries both spellings at once. + * + * ## Read-only, and that is the ruled outcome rather than a limitation + * + * Nothing below changes. The gates still read the post-hoist node only, so + * every verdict, every hoisted value and every byte the element receives is + * what it was - the alias is REFUSED, not honoured. The opposite arm was + * built and closed (PR objectui#9144): honouring it would have made *"8 + * predicate keys work while the rest stayed silently dropped - and partly + * working is harder to learn from than not working"*. + * + * The bag handed over is {@link propsWithoutCanonicalKeys}'s, the SAME + * subtraction the outgoing props bag uses, so a key the canonical bag also + * declares is not reported as parked: there the author is already getting + * the canonical answer (objectui#5123). The key SET is + * {@link NODE_GATE_PREDICATE_KEYS}, derived from the two chain declarations + * above rather than re-listed here. + */ + reportRefusedPropsPredicate( + newSchema.type, + newSchema.id, + NODE_GATE_PREDICATE_KEYS, + propsWithoutCanonicalKeys(newSchema.props, newSchema.properties), + ); + // Evaluate visibility: visibleWhen / visible / visibleOn / visibility / hidden / hiddenOn const shouldHide = (() => { // `visibleWhen` is the single canonical conditional-visibility predicate diff --git a/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx b/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx new file mode 100644 index 0000000000..c7556f553a --- /dev/null +++ b/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx @@ -0,0 +1,315 @@ +/** + * 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. + */ + +/** + * objectui#9108 - a node-gate predicate parked under `props` is REFUSED BY + * NAME, loudly: not honoured, and not silently dropped. + * + * ## What was measured, and why it could not be seen + * + * A node may spell its config bag `properties` (the spec spelling) or `props` + * (the annotated legacy alias). `SchemaRenderer`'s hoist copies `properties.*` + * onto the node; nothing copies `props.*`. Both node gates read the post-hoist + * node, so a predicate authored under the alias was never one of the keys + * either gate could see: + * + * | authored | observed | + * |----------------------------------|--------------------| + * | `props: { visible: false }` | the node RENDERED | + * | `props: { hidden: true }` | the node RENDERED | + * | `properties: { visible: false }` | correctly hidden | + * | `properties: { hidden: true }` | correctly hidden | + * + * Fail-OPEN and silent: a gate that never bit renders exactly like a gate that + * said yes, so no user and no screenshot can find it. + * + * ## Which half of that this suite pins + * + * BOTH, and they are not the same assertion. The ruling of 2026-09-13 closed + * the honour arm (PR objectui#9144) and ruled REFUSE, so: + * + * - the VERDICT rows below must read exactly as the defect table above - the + * alias still gates nothing. A suite that only pinned the console line + * would go green on a tree that had quietly started honouring the alias, + * which is the arm the maintainer refused; + * - the REFUSAL rows must name the key on the console. A suite that only + * pinned the verdicts would go green on today's silence, which is the + * defect. + * + * ## Both polarities, one harness, in one file + * + * Every verdict row is measured in BOTH directions - a truthy predicate and a + * falsy one - through the same probe in the same suite. A pair of EQUAL + * verdicts is the signature of a gate that was never consulted, whichever way + * it landed, and that pair is exactly what the `props` rows must still produce. + * The `properties` rows are the live control: "the node is hidden" is equally + * satisfied by a renderer that hides everything, by a broken registry, and by a + * probe that never mounted, so the control has to run here rather than be + * asserted elsewhere. + * + * ## Plain booleans on purpose + * + * objectui#9100 and objectui#9107 are about a CEL envelope being flattened on + * the way to the engine. This defect is present with a plain boolean and no + * expression anywhere, and it predates both repairs, so nothing here carries an + * envelope: an envelope would make a failure ambiguous between the two causes. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '../SchemaRenderer'; +import { SchemaRendererContext } from '../context/SchemaRendererContext'; +import { + REFUSED_PROPS_PREDICATE_PREFIX, + __resetRefusedPropsPredicateWarnings, +} from '../utils/propsBagDiagnostic'; + +/** A plain node type - nothing in this repo reads a config bag for it. */ +const TYPE = 'probe-9108'; + +/** + * A node type in the BAG-READING family. `readsPropsBag` keys on the + * `element:` prefix, so this exercises the family carve-out without pulling + * `@object-ui/components` into this package's test graph. + */ +const ELEMENT_TYPE = 'element:probe-9108'; + +/** + * Reports what a `schema`-reading renderer can see, so one probe answers both + * "did the gate bite?" and "was anything hoisted?". + */ +const Probe = (props: { schema?: Record; disabled?: unknown }) => ( +
+); + +function mount(schema: unknown) { + return render( + + + , + ); +} + +const rendered = (): boolean => screen.queryByTestId('probe') !== null; + +/** One authored bag, mounted twice - predicate `true` then `false`. */ +function pair( + bag: 'props' | 'properties', + key: string, + type: string = TYPE, +): { truthy: boolean; falsy: boolean } { + const once = (value: boolean): boolean => { + mount({ type, [bag]: { [key]: value } }); + const r = rendered(); + cleanup(); + return r; + }; + return { truthy: once(true), falsy: once(false) }; +} + +/** + * Only the lines THIS card emits. `console.error` is a shared channel - React + * and the schema validator both use it - so a spy read raw would pass on the + * wrong line and fail on an unrelated one. + */ +let errorSpy: ReturnType; +const refusals = (): string[] => + errorSpy.mock.calls + .map((args: unknown[]) => String(args[0])) + .filter((line: string) => line.startsWith(REFUSED_PROPS_PREDICATE_PREFIX)); + +beforeEach(() => { + ComponentRegistry.register(TYPE, Probe as never); + ComponentRegistry.register(ELEMENT_TYPE, Probe as never); + __resetRefusedPropsPredicateWarnings(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + errorSpy.mockRestore(); + cleanup(); + ComponentRegistry.unregister?.(TYPE); + ComponentRegistry.unregister?.(ELEMENT_TYPE); +}); + +describe('objectui#9108 - the alias still gates NOTHING (the refused arm)', () => { + // SHOW polarity. The `props` rows must produce a pair of EQUAL verdicts - + // the signature of a gate that was never consulted. The `properties` rows + // are the control and must still discriminate. + it.each([ + ['visibleWhen'], + ['visible'], + ['visibleOn'], + ['visibility'], + ] as const)('props.%s does not gate, properties.%s does', key => { + expect(pair('props', key)).toEqual({ truthy: true, falsy: true }); + expect(pair('properties', key)).toEqual({ truthy: true, falsy: false }); + }); + + // HIDE polarity - the opposite direction, and the reason a one-polarity + // suite would have passed on half the defect. + it.each([['hidden'], ['hiddenOn']] as const)( + 'props.%s does not gate, properties.%s does', + key => { + expect(pair('props', key)).toEqual({ truthy: true, falsy: true }); + expect(pair('properties', key)).toEqual({ truthy: false, falsy: true }); + }, + ); + + // The enablement gate, same bag, quieter symptom: a greyed control is still + // on screen. `disabled` under `props` reaches the element as a React prop and + // is then overwritten by the gate's own `disabled`, which is `undefined` + // because the gate never saw the key. + it('props.disabled does not reach the enablement gate, properties.disabled does', () => { + mount({ type: TYPE, props: { disabled: true } }); + expect(screen.getByTestId('probe').getAttribute('data-disabled')).toBe('false'); + cleanup(); + mount({ type: TYPE, properties: { disabled: true } }); + expect(screen.getByTestId('probe').getAttribute('data-disabled')).toBe('true'); + }); + + // The fence the ruling is built on: the refusal READS the bag to report it. + // If this row ever flips, the alias has been hoisted and the honour arm the + // maintainer closed has come back in through a diagnostic. + it('nothing is hoisted - `schema.visible` stays undefined for the renderer', () => { + mount({ type: TYPE, props: { visible: true } }); + expect(screen.getByTestId('probe').getAttribute('data-node-visible')).toBe('undefined'); + cleanup(); + // The canonical spelling IS hoisted, as it always was - the control that + // proves the assertion above is reading a real attribute. + mount({ type: TYPE, properties: { visible: true } }); + expect(screen.getByTestId('probe').getAttribute('data-node-visible')).toBe('true'); + }); +}); + +describe('objectui#9108 - and it is refused BY NAME, loudly', () => { + it.each([ + ['visibleWhen'], + ['visible'], + ['visibleOn'], + ['visibility'], + ['hidden'], + ['hiddenOn'], + ['disabled'], + ['disabledOn'], + ] as const)('props.%s is named on the console', key => { + mount({ type: TYPE, id: 'n1', props: { [key]: true } }); + const lines = refusals(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(`\`${key}\``); + // The address, so the author can find the node rather than the key alone. + expect(lines[0]).toContain(`\`${TYPE}\``); + expect(lines[0]).toContain("id: 'n1'"); + }); + + // The ruling requires the refusal to carry a NAMED MIGRATION LINE rather than + // let half the keys quietly start working. + it('carries the migration line, naming both spellings that do work', () => { + mount({ type: TYPE, props: { visible: false } }); + const [line] = refusals(); + expect(line).toContain('MIGRATION:'); + expect(line).toContain('`properties`'); + expect(line).toContain('visibleWhen'); + expect(line).toContain('objectui#9108'); + }); + + // Two keys in one bag are one authoring mistake and get one line naming both. + it('names every parked key in one line', () => { + mount({ type: TYPE, props: { visible: false, hiddenOn: true } }); + const [line] = refusals(); + expect(refusals()).toHaveLength(1); + expect(line).toContain('`visible`'); + expect(line).toContain('`hiddenOn`'); + }); + + // Both halves of the rate limit, together: a test that pins only the first + // cannot tell a working dedupe from one that suppresses everything. + it('reports once per distinct authoring bug, and a second bug still reports', () => { + mount({ type: TYPE, props: { visible: false } }); + cleanup(); + mount({ type: TYPE, props: { visible: false } }); + expect(refusals()).toHaveLength(1); + cleanup(); + mount({ type: TYPE, id: 'other', props: { hidden: true } }); + expect(refusals()).toHaveLength(2); + }); +}); + +describe('objectui#9108 - what the refusal must stay SILENT about', () => { + it('the canonical spelling is not refused', () => { + mount({ type: TYPE, properties: { visible: false } }); + expect(refusals()).toEqual([]); + }); + + // objectui#5123, maintainer ruling 2026-08-18: `properties` wins on both + // channels, so a key BOTH bags declare is already answered canonically and + // nothing is parked. Measured in both directions, so a suite that reported + // nothing at all could not pass. + it('a key BOTH bags declare is answered by `properties`, and not refused', () => { + mount({ type: TYPE, props: { visible: true }, properties: { visible: false } }); + expect(rendered()).toBe(false); + expect(refusals()).toEqual([]); + cleanup(); + mount({ type: TYPE, props: { visible: false }, properties: { visible: true } }); + expect(rendered()).toBe(true); + expect(refusals()).toEqual([]); + }); + + it('a non-predicate key under `props` is not this card\'s subject', () => { + mount({ type: TYPE, props: { title: 'Customer Summary' } }); + expect(refusals()).toEqual([]); + }); + + // objectui#6752 / objectui#6760: a degenerate bag declares no key for either + // spelling, and must not have its shape reinterpreted here either - the + // refusal must not report nine keys named `0` … `8`. + it('a degenerate `props` refuses nothing', () => { + mount({ type: TYPE, props: 'not-a-bag' }); + expect(rendered()).toBe(true); + expect(refusals()).toEqual([]); + }); + + // `enabled` is in PREDICATE_CHAIN_KEYS because the config-bag loops flatten + // it, but no gate in SchemaRenderer consults it - the action renderers read + // it one layer down and negate it. Refusing it here would state, of a key + // this file never asks about, that a gate in this file could not see it. + it('`enabled` is not a node-gate key and is not refused here', () => { + mount({ type: TYPE, props: { enabled: false } }); + expect(refusals()).toEqual([]); + }); + + // Measured carve-out: `element:button` / `element:text-input` really do read + // `props.disabled` out of the bag (`disabled={props.disabled || running}`), + // so the author got the effect they asked for and a refusal would send them + // looking for a defect that is not on their screen. + it('`disabled` on a bag-reading node is honoured by the renderer, not refused', () => { + mount({ type: ELEMENT_TYPE, props: { disabled: true } }); + expect(refusals()).toEqual([]); + }); + + // ...and the carve-out is exactly one key wide. Nothing in this repo reads a + // visibility-chain key out of a config bag, so those rows stay refused on the + // bag-reading family too - if this row ever goes silent, the carve-out has + // widened from a measurement into a blanket. + it('a visibility key on a bag-reading node is still refused', () => { + mount({ type: ELEMENT_TYPE, props: { visible: false } }); + const lines = refusals(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('`visible`'); + }); +}); diff --git a/packages/react/src/utils/propsBagDiagnostic.ts b/packages/react/src/utils/propsBagDiagnostic.ts index db7beaa9be..5469d86fba 100644 --- a/packages/react/src/utils/propsBagDiagnostic.ts +++ b/packages/react/src/utils/propsBagDiagnostic.ts @@ -255,3 +255,210 @@ export function reportDroppedPropsBag( console.warn(message); return message; } + +/* -------------------------------------------------------------------------- * + * objectui#9108 - the NODE-GATE half of the same bag + * -------------------------------------------------------------------------- */ + +/** + * Refusal: a NODE-GATE PREDICATE was parked under `props` (objectui#9108, + * maintainer ruling 2026-09-13, verbatim 「同意」 on the `domain:spec` seat's + * recommendation - REFUSE). + * + * ## The defect this refuses + * + * Measured at node level on `1e0e46af9`, four rows, both spellings and both + * polarities: + * + * { type: 'card', props: { visible: false } } -> the node RENDERED + * { type: 'card', props: { hidden: true } } -> the node RENDERED + * { type: 'card', properties: { visible: false } } -> correctly hidden + * { type: 'card', properties: { hidden: true } } -> correctly hidden + * + * `SchemaRenderer` hoists `properties.*` onto the node; NOTHING copies + * `props.*`. Both node gates read the post-hoist node, so a predicate that + * arrived under the alias is never one of the keys either gate can see. It is + * fail-OPEN and silent by construction - a gate that never bit renders exactly + * like a gate that said yes - so no user, no screenshot and no snapshot can + * find it. Only counting can. + * + * ## Why REFUSE and not HONOUR - this is a ruling, not a preference + * + * The opposite arm was implemented and CLOSED (PR objectui#9144). The ruling's + * decisive axis: honouring the alias would have made + * *"8 predicate keys work while the rest stayed silently dropped - and partly + * working is harder to learn from than not working"*, because the author + * generalises "`props` is fine" and collides again on the next key with the + * cause now further away. The three measured readings behind it, each with a + * control that fires: `@objectstack/spec` already refuses `props` by name + * (`unrecognized_keys ["props"]`); `skills/objectui/rules/protocol.md` teaches + * it as an ERROR rather than as a gap; and the producer census found ZERO + * authored `props` bags across `examples/**` and `content/docs/**` (controls: + * `visibleWhen` 9 files, `type` 488 files), so there is no capability to cut. + * + * ## What it deliberately does NOT do + * + * It changes NO verdict and NO rendered byte. Every gate answers exactly what + * it answered before, nothing is hoisted, `schema.` stays undefined for a + * renderer declared as `({ schema })`, and the bag reaches the element with the + * same keys it always did. The trap stops being SILENT; it does not stop being + * a trap, which is the same posture objectui#6708 took one card earlier and the + * only posture a diagnostic can take without becoming the honour arm the ruling + * refused. + * + * ## Why `console.error`, and why NOT dev-only + * + * Two established postures in this tree, and this leg needs a leg of each: + * + * - SEVERITY from `unevaluatedExpression.ts`, which is this repo's REFUSAL + * tier. Its neighbour {@link reportDroppedPropsBag} warns because it + * reports a value that was dropped; the ruling's word here is *refused*, + * and the sibling gate diagnostic states the split explicitly ("not the + * refusal `reportUnevaluatedExpressions` emits"). + * - ALWAYS-ON from `visibilityDiagnostic.ts`'s unresolvable-predicate leg, + * which the maintainer took out of `__DEV__` in objectui#6038 (ruling + * 2026-08-25, option B: "the silence is no longer an accepted property") + * on the ground that a node gate which has stopped biting in production is + * a class-1 defect that must not be able to sit live and undiscovered. + * This is that class in its widest form - the gate never bit at all, on + * every render - and the ruling's own confidence gap is about exactly the + * population a `__DEV__` gate would silence: *"The producer census covers + * this repository's `examples/**`, `content/docs/**` and skills corpus. + * It does NOT measure authored metadata in production. If stored documents + * out there do carry `props`, those authors are already suffering the + * silent drop today."* + * + * The rate limit that makes an always-on console line affordable is the same + * one objectui#6038 required: the dedupe below is keyed on the MESSAGE, so the + * ceiling is one line per distinct authoring bug for the lifetime of the page, + * not one line per render and not one per node. + */ +export const REFUSED_PROPS_PREDICATE_PREFIX = + '[ObjectUI] A node-gate predicate under `props` is REFUSED'; + +/** + * Node-gate predicate keys the BAG-READING family can still honour itself, so + * refusing them there would cry wolf. + * + * Measured on this tree, not guessed: across every renderer that reads a config + * bag ({@link readsPropsBag} - the five `element:*` `readProps()` sites plus + * `view:simple`), the only node-gate predicate key any of them reads out of the + * bag is `disabled`, in `elements.tsx` (`element:button`: + * `disabled={props.disabled || running}`) and `text-input.tsx` + * (`element:text-input`). No visibility-chain key is read from a bag by any + * renderer in this repo, and neither is `disabledOn`. + * + * So `{ type: 'element:button', props: { disabled: true } }` really does + * produce a disabled button - the node GATE did not bite, but the author got + * the effect they asked for, and a refusal there would send them looking for a + * defect that is not on their screen. Every other row stays refused on every + * family, including the `element:*` visibility rows, which nothing honours. + */ +const BAG_HONOURED_GATE_KEYS: ReadonlySet = new Set(['disabled']); + +/** + * Build the refusal message. Separate from the emit so a test can assert the + * words a developer is going to read, not merely that something was logged. + * + * Carries the MIGRATION LINE the ruling requires by name - *"the implementing + * round should pair the refusal with a named migration line in the diagnostic, + * not let half the keys quietly start working"*. It names the keys it refused + * and the two spellings that do work, and it names nothing else: there is no + * per-key canonical-alias table here, because inventing one would be a second + * declaration of ADR-0089's normalization competing with the spec's own. + */ +export function formatRefusedPropsPredicateMessage( + type: unknown, + id: unknown, + refusedKeys: readonly string[], +): string { + const keyList = refusedKeys.map(k => `\`${k}\``).join(', '); + const one = refusedKeys.length === 1; + return ( + `${REFUSED_PROPS_PREDICATE_PREFIX} - node ${describeAddress(type, id)}\n` + + ` ${one ? 'Key' : 'Keys'} parked under \`props\`: ${keyList}\n` + + '`props` is NOT an authoring surface for the node gates. `SchemaRenderer`\n' + + 'hoists `properties.*` onto the node and reads its visibility / enablement\n' + + 'gates from there; nothing copies `props.*`, so this predicate is never a key\n' + + `either gate can see. ${one ? 'It gates' : 'They gate'} nothing - the node renders and stays enabled\n` + + 'exactly as if the predicate said yes, which is why nothing on screen says so.\n' + + ` MIGRATION: move ${one ? 'it' : 'them'} out of \`props\` - onto the node itself\n` + + '(`{ "type": "card", "visibleWhen": ... }`, the spelling the spec declares), or\n' + + 'into the `properties` bag, which IS hoisted. The worked pair is in\n' + + '`skills/objectui/rules/protocol.md`. (objectui#9108)' + ); +} + +/** + * Which node-gate predicate keys of the PARKED `props` bag are refused, or + * `null` when there is nothing to say. + * + * `nodeGateKeys` is passed IN rather than declared here: `SchemaRenderer` owns + * the two chain declarations and derives this union from them, so a leg added + * to either chain is covered by the same edit that adds it and this module + * never grows a twin list to drift from them (AGENTS.md #9). + * + * `parkedPropsBag` is the value of + * `propsWithoutCanonicalKeys(schema.props, schema.properties)` - the same + * subtraction the outgoing bag and the objectui#6708 diagnostic already use, so + * the two cases stay un-confused exactly as they are there: a key BOTH bags + * declare has already been subtracted (objectui#5123: `properties` wins, the + * author is getting the canonical answer and nothing is parked), while a key + * only `props` declares survives into that bag and is precisely the one no gate + * can see. A degenerate `props` contributes no keys through that same function, + * so `props: 'not-a-bag'` refuses nothing here - its defect is a different + * question (objectui#6752). + */ +export function collectRefusedPropsPredicateKeys( + type: unknown, + nodeGateKeys: ReadonlySet, + parkedPropsBag: unknown, +): string[] | null { + if (!isConfigBag(parkedPropsBag)) return null; + const bagReader = readsPropsBag(type); + const refused = Object.keys(parkedPropsBag).filter( + key => nodeGateKeys.has(key) && !(bagReader && BAG_HONOURED_GATE_KEYS.has(key)), + ); + return refused.length > 0 ? refused : null; +} + +/** + * Reported messages, so a re-render - or a second node carrying the same + * authoring bug - does not repeat the line. Module state, exactly like + * {@link reportDroppedPropsBag}'s `Set` next door, and reset the same way for + * tests. + */ +const _refusedPropsPredicates = new Set(); + +/** + * Test-only reset for the dedupe above. Without it the second test to assert + * the same refusal reads the first test's dedupe entry and sees silence - a + * green run that checked nothing. + */ +export function __resetRefusedPropsPredicateWarnings(): void { + _refusedPropsPredicates.clear(); +} + +/** + * Reports a node-gate predicate parked under `props`, in DEVELOPMENT AND IN + * PRODUCTION - read {@link REFUSED_PROPS_PREDICATE_PREFIX}'s docblock for why + * this leg carries neither the `__DEV__` gate nor the `console.warn` severity + * of its neighbour in this module. + * + * Returns the message it emitted (or `null`) so a caller or a test can read the + * decision rather than infer it from a spy. + */ +export function reportRefusedPropsPredicate( + type: unknown, + id: unknown, + nodeGateKeys: ReadonlySet, + parkedPropsBag: unknown, +): string | null { + const refusedKeys = collectRefusedPropsPredicateKeys(type, nodeGateKeys, parkedPropsBag); + if (!refusedKeys) return null; + const message = formatRefusedPropsPredicateMessage(type, id, refusedKeys); + if (_refusedPropsPredicates.has(message)) return null; + _refusedPropsPredicates.add(message); + console.error(message); + return message; +} diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md index 9c39edab32..2956046109 100644 --- a/skills/objectui/rules/protocol.md +++ b/skills/objectui/rules/protocol.md @@ -107,9 +107,12 @@ Every `ui:*` / `page:*` renderer reads its configuration off the node — `schema.title`, `schema.content`, `schema.value`, `schema.columns`. `SchemaRenderer` does **not** merge `schema.props` into the node; it spreads it as React props (`packages/react/src/SchemaRenderer.tsx`), which those renderers -ignore. A key parked under `props` is therefore silently dropped: the component -renders an empty frame, and the envelope itself lands in the DOM as the invalid -attribute `props="[object Object]"`. +ignore. A key parked under `props` is therefore dropped: the component renders +an empty frame, and the envelope itself lands in the DOM as the invalid +attribute `props="[object Object]"`. Silent for every key except the node-gate +predicates (`visibleWhen` / `visible` / `visibleOn` / `visibility` / `hidden` / +`hiddenOn` / `disabled` / `disabledOn`), which are refused by name on the +console, with the migration, instead of gating nothing (objectui#9108). **❌ WRONG — renders an empty card:**