From 094028045e8acd23ed17e42beb7a913621417a96 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:50:02 +0000 Subject: [PATCH 1/3] refactor(react,plugin-form,console): read visibleWhen only; the spec retires visibleOn and visibility @objectstack/spec 18 retires the form view field's and section's `visibleOn` and the page component's `visibility` as retiredKey tombstones naming `visibleWhen` (objectstack#23543 F4). The readers of the spec keys stop folding them back: - SchemaRenderer drops the `visibility` leg, its working-copy type member and its prop strip; BaseSchema's own deprecated `visibleOn` is a different key and stays. - plugin-form's normalizeSectionField carries a spec field's `visibleWhen` only into the runtime field's view-level slot; the runtime FormField's own `visibleOn` slot is unchanged. - The console FormPage builds field and section rows from `visibleWhen` only. The spec-parity coupling walks to the FormFieldSchema shape through a pipe when there is one, so it holds on next.2 and on spec 18; the expressions test's probe node declares `visibility` itself instead of picking it off PageComponent. Pins: a raw schema with the retired key alone gates nothing, and the same predicate under `visibleWhen` does. Claude-Session: https://claude.ai/code/session_01SdA7b5ZrdDDBDKG48XRqy7 Co-authored-by: Claude --- .changeset/12654-console-visibleon-retired.md | 7 ++ .../12654-plugin-form-visibleon-retired.md | 9 +++ .changeset/12654-react-visibility-retired.md | 9 +++ .../FormPage.conditionalRules.test.tsx | 40 ++++++------ apps/console/src/components/FormPage.tsx | 64 ++++++++----------- .../components/FormPage.visibleWhen.test.tsx | 46 ++++++------- content/docs/guide/metadata-diagnostics.md | 5 +- content/docs/plugins/plugin-form.mdx | 2 +- .../src/sectionFields.blankGate-11262.test.ts | 7 +- .../src/sectionFields.spec-parity.test.ts | 39 ++++++++--- .../plugin-form/src/sectionFields.test.ts | 38 ++++++----- packages/plugin-form/src/sectionFields.ts | 16 +++-- packages/react/src/SchemaRenderer.tsx | 57 ++++++----------- ...er.concealmentGateFaultDiagnostic.test.tsx | 11 ++-- ...derer.disabledGateFaultDiagnostic.test.tsx | 3 +- .../SchemaRenderer.expressions.test.tsx | 40 ++++++------ ...nderer.predicateEnvelopeConfigBag.test.tsx | 2 +- ...chemaRenderer.propsBagLoudRefusal.test.tsx | 2 - ...r.unevaluatedExpressionDiagnostic.test.tsx | 1 - ...Renderer.visibleWhenRecordBinding.test.tsx | 11 ++-- .../react/src/utils/visibilityDiagnostic.ts | 6 +- 21 files changed, 218 insertions(+), 197 deletions(-) create mode 100644 .changeset/12654-console-visibleon-retired.md create mode 100644 .changeset/12654-plugin-form-visibleon-retired.md create mode 100644 .changeset/12654-react-visibility-retired.md diff --git a/.changeset/12654-console-visibleon-retired.md b/.changeset/12654-console-visibleon-retired.md new file mode 100644 index 0000000000..5a1dbc2448 --- /dev/null +++ b/.changeset/12654-console-visibleon-retired.md @@ -0,0 +1,7 @@ +--- +'@object-ui/console': minor +--- + +The console's form page (`FormPage`, the `/forms/:name` and public `/f/:slug` routes) reads a form view field's and section's visibility predicate from `visibleWhen` only (objectui#12654). `@objectstack/spec` 18 retires both `visibleOn` keys as `retiredKey` tombstones naming `visibleWhen` (objectstack#23543 F4). + +What a raw schema loses: a form view handed to this page without passing through the spec's parse, whose field or section carries only `visibleOn`, no longer hides that field or section; write `visibleWhen`. What is unaffected: form views served by an ObjectStack runtime, which already arrive with `visibleWhen` (the spec's parse fold through the 18 pre-releases, and spec 18's stored-row rehydration). diff --git a/.changeset/12654-plugin-form-visibleon-retired.md b/.changeset/12654-plugin-form-visibleon-retired.md new file mode 100644 index 0000000000..b751ac5573 --- /dev/null +++ b/.changeset/12654-plugin-form-visibleon-retired.md @@ -0,0 +1,9 @@ +--- +'@object-ui/plugin-form': minor +--- + +A form view field's `visibleOn` is no longer read when sections are normalized (`normalizeSectionField`, used by `ObjectForm`, `ModalForm` and the record form page) (objectui#12654). `@objectstack/spec` 18 retires the form view field's `visibleOn` as a `retiredKey` tombstone naming `visibleWhen` (objectstack#23543 F4), so the field's `visibleWhen` is the one view-level predicate carried onto the runtime field. + +What a raw schema loses: a form view section entry `{ field: 'x', visibleOn: '' }` that did not pass through the spec's parse no longer hides the field; write `visibleWhen`. A runtime `FormField` (an inline field object with its own `name`) keeps its `visibleOn` slot: that is the runtime field's view-level gate, a different key, and it is unchanged. + +What is unaffected: form views served by an ObjectStack runtime. They already arrive with `visibleWhen`: the spec's parse folds the old key through the 18 pre-releases, and spec 18's stored-row rehydration rewrites a row stored with it. diff --git a/.changeset/12654-react-visibility-retired.md b/.changeset/12654-react-visibility-retired.md new file mode 100644 index 0000000000..f6531bad68 --- /dev/null +++ b/.changeset/12654-react-visibility-retired.md @@ -0,0 +1,9 @@ +--- +'@object-ui/react': minor +--- + +`SchemaRenderer` no longer reads a page component's `visibility` key (objectui#12654). `@objectstack/spec` 18 retires it as a `retiredKey` tombstone naming `visibleWhen` (objectstack#23543 F4), so `visibleWhen` is the one conditional-visibility key the renderer reads for it. + +What a raw schema loses: a node handed straight to `SchemaRenderer`, without passing through the spec's parse, that carries only `visibility: ''` is no longer hidden when the predicate is false; it now draws, and the `visibility` value is no longer stripped before the component's props. Write `visibleWhen` instead. The `SchemaRenderer` working-copy type no longer declares `visibility`. + +What is unaffected: metadata served by an ObjectStack runtime. A served page row already reaches the renderer with `visibleWhen`: the spec's parse folds the old key through the 18 pre-releases, and spec 18's stored-row rehydration rewrites a row stored with it. `BaseSchema`'s own deprecated `visibleOn` is a different key and is still read. diff --git a/apps/console/src/components/FormPage.conditionalRules.test.tsx b/apps/console/src/components/FormPage.conditionalRules.test.tsx index a284f8b365..0ab6673b2c 100644 --- a/apps/console/src/components/FormPage.conditionalRules.test.tsx +++ b/apps/console/src/components/FormPage.conditionalRules.test.tsx @@ -2,7 +2,7 @@ /** * objectui#5627 — this renderer honours the OTHER THREE conditional-rule - * surfaces: section-level `visibleWhen`/`visibleOn`, and the object-level field + * surfaces: section-level `visibleWhen`, and the object-level field * rules `visibleWhen`/`readonlyWhen`/`requiredWhen`. * * ## Why a second pin file next to `FormPage.visibleWhen.test.tsx` @@ -37,7 +37,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { buildSections, FormPage, isSectionVisible, resolveRowState } from './FormPage'; @@ -229,33 +229,28 @@ describe('objectui#5627 — FormPage evaluates SECTION-level visibility', () => expect(screen.getByLabelText('Status')).toBeInTheDocument(); }); - it('honours the deprecated ADR-0089 alias `visibleOn` on a section', async () => { - // The spec normaliser rewrites the alias, so a spec-served view never - // carries it — but hand-written layouts and this app's own create schemas - // still do, which is why every sibling reader keeps reading it. + // objectui#12654: spec 18 retires the section's `visibleOn` spelling + // (objectstack#23543 F4). A raw view still carrying it is refused by the + // spec's parse; this reader does not fold it back. The pair below is the + // narrowing: the SAME predicate hides the section under `visibleWhen` and + // gates nothing under the retired key. + it('does not read the retired `visibleOn` on a section — the same predicate under `visibleWhen` does hide it', async () => { renderInternal([ { label: 'Basics', fields: ['priority'] }, { label: 'Escalation', visibleOn: "record.priority == 'urgent'", fields: ['notes'] }, ]); await awaitForm(); - expect(screen.queryByText('Escalation')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument(); - }); + expect(screen.getByText('Escalation')).toBeInTheDocument(); + expect(screen.getByLabelText('Notes')).toBeInTheDocument(); + cleanup(); - it('lets the canonical `visibleWhen` win when a section authors both spellings', async () => { - // The two predicates disagree on purpose: only the canonical one's verdict - // can produce this outcome. renderInternal([ { label: 'Basics', fields: ['priority'] }, - { - label: 'Escalation', - visibleWhen: "record.priority == 'urgent'", // false -> hidden - visibleOn: "record.priority == 'low'", // true -> would show - fields: ['notes'], - }, + { label: 'Escalation', visibleWhen: "record.priority == 'urgent'", fields: ['notes'] }, ]); await awaitForm(); expect(screen.queryByText('Escalation')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument(); }); it('honours the { dialect, source } wire shape on a section', async () => { @@ -563,23 +558,24 @@ describe('objectui#5627 — requiredWhen takes the serverOwnedValue carve-out', // ─── 4. The row-building step the DOM cases stand on ───────────────────── describe('objectui#5627 — the keys reach the rows, in their own slots', () => { - it('buildSections carries the SECTION predicate onto the row, canonical first', () => { + it('buildSections carries the SECTION predicate onto the row, from `visibleWhen` only', () => { const sections = buildSections( { sections: [ { label: 'a', visibleWhen: 'record.x == 1', fields: ['f'] }, - { label: 'b', visibleOn: { dialect: 'cel', source: 'record.x == 2' }, fields: ['f'] }, - { label: 'c', visibleWhen: 'record.x == 3', visibleOn: 'record.x == 4', fields: ['f'] }, + { label: 'b', visibleWhen: { dialect: 'cel', source: 'record.x == 2' }, fields: ['f'] }, + { label: 'c', visibleOn: 'record.x == 4', fields: ['f'] }, { label: 'd', fields: ['f'] }, ], }, null, ); + // `c`: the retired spelling reaches no row (objectui#12654). expect(sections.map((s) => s.visibleWhen)).toEqual([ 'record.x == 1', { dialect: 'cel', source: 'record.x == 2' }, - 'record.x == 3', + undefined, undefined, ]); }); diff --git a/apps/console/src/components/FormPage.tsx b/apps/console/src/components/FormPage.tsx index 95bf75de28..fbee1fe5f2 100644 --- a/apps/console/src/components/FormPage.tsx +++ b/apps/console/src/components/FormPage.tsx @@ -52,8 +52,7 @@ * ## Conditional field visibility (objectui#5594) * * A FormView field may carry a CEL predicate saying WHEN it is visible — - * `visibleWhen`, or the deprecated ADR-0089 alias `visibleOn`. This renderer - * read neither, so such a field rendered unconditionally on both routes: + * `visibleWhen` (ADR-0089). This renderer read it not at all before #5594, so such a field rendered unconditionally on both routes: * fail-open and silent, with no diagnostic for the author. * * It is the SECOND form renderer in this repo, which is why the gap survived. @@ -74,7 +73,7 @@ * and for the same reason: nothing downstream could read a key this file's * payload types did not admit. * - * - SECTION-level `visibleWhen` / `visibleOn` (ADR-0089). The section type is + * - SECTION-level `visibleWhen` (ADR-0089). The section type is * no longer this file's to widen — objectui#5596 replaced the hand copy with * the shared `FormSectionSpec`, which has DECLARED these two keys ever since * ("declaring a key is not honouring it", as that type's own docstring says @@ -477,15 +476,15 @@ interface RenderableField { * this one by {@link resolveRowState} (objectui#5627). Two layers may both * condition one control, and either one hiding it is enough. * - * Resolved CANONICAL-FIRST when the row is built: `visibleWhen ?? visibleOn`. - * ADR-0089 renamed the key, and `@objectstack/spec`'s normaliser REWRITES the - * alias rather than keeping both, so a spec-served FormView carries - * `visibleWhen` and no `visibleOn` at all. The deprecated spelling is still - * read because it still has live producers — hand-written layouts, and this - * app's own create schemas, which never pass through that normaliser. Same - * spelling and same precedence as the two sibling readers, deliberately: - * `@object-ui/plugin-form` `sectionFields.ts` (`fd.visibleWhen ?? fd.visibleOn`) - * and app-shell's metadata-admin `readVisibility`. + * Read from `visibleWhen` ONLY (objectui#12654). ADR-0089 renamed the key, + * and `@objectstack/spec` 18 retires the old `visibleOn` spelling as a + * `retiredKey` tombstone (objectstack#23543 F4): every parsing door refuses + * it by name, and a stored row written with it is rehydrated to + * `visibleWhen` before it is served. A raw view still carrying `visibleOn` + * is therefore an authoring mistake the spec reports, and this reader does + * not fold it back — a renderer-side `??` would hide that mistake. The + * runtime form chain reads the same single key + * (`@object-ui/plugin-form` `sectionFields.ts`). * * The TYPE is derived from the authoring surface rather than restated: * re-spelling `string | { dialect?, source }` here would be a third @@ -578,18 +577,14 @@ interface RenderableSection { collapsible: boolean; collapsed: boolean; /** - * Conditional visibility for the whole section (ADR-0089), resolved - * CANONICAL-FIRST when the row is built: `visibleWhen ?? visibleOn` - * (objectui#5627). + * Conditional visibility for the whole section (ADR-0089, objectui#5627), + * read from `visibleWhen` ONLY when the row is built (objectui#12654). * - * The same key, the same precedence and the same reason as the field row one - * type up — the spec's normaliser rewrites the alias, so a spec-served - * FormView carries only `visibleWhen`, while hand-written layouts and this - * app's own create schemas still produce the deprecated spelling. app-shell's - * `SchemaForm` reads its sections through a `readVisibility` helper spelling - * exactly this `??`; that helper is module-private there (it is not part of - * `@object-ui/app-shell`'s published surface), so the shared machinery this - * file can actually import is the ENGINE — `evalFieldPredicate`, via + * The same key and the same reason as the field row one type up: spec 18 + * retires the section's `visibleOn` spelling, so a served FormView carries + * only `visibleWhen`, and a raw view still writing the old key is refused + * by the spec rather than folded here. The shared machinery this file + * imports is the ENGINE — `evalFieldPredicate`, via * {@link isSectionVisible} — which is the half that decides anything. * * Type derived from the shared authoring surface (objectui#5596's @@ -783,11 +778,10 @@ export function buildSections( required: override.required ?? def.required ?? false, readonly: override.readonly ?? false, hidden: override.hidden ?? false, - // Canonical key wins over the deprecated alias — see - // `RenderableField.visibleWhen`. Carried, not evaluated: the verdict - // depends on the live form values, which change per keystroke, while - // these rows are memoized on the loaded spec. - visibleWhen: override.visibleWhen ?? override.visibleOn, + // The one spelling — see `RenderableField.visibleWhen`. Carried, not + // evaluated: the verdict depends on the live form values, which change + // per keystroke, while these rows are memoized on the loaded spec. + visibleWhen: override.visibleWhen, // The OBJECT's own rules (ADR-0036), off the object field and NOT // merged with the view predicate above: two layers, two slots, ANDed // at render by `resolveRowState` (objectui#5627). A `??` here would be @@ -838,9 +832,9 @@ export function buildSections( columns: cols, collapsible: !!sec.collapsible, collapsed: !!sec.collapsed, - // Canonical key wins over the deprecated alias, same precedence as the - // field row — see `RenderableSection.visibleWhen` (objectui#5627). - visibleWhen: sec.visibleWhen ?? sec.visibleOn, + // The one spelling, as on the field row — see + // `RenderableSection.visibleWhen` (objectui#5627, objectui#12654). + visibleWhen: sec.visibleWhen, fields, }; }); @@ -894,11 +888,7 @@ export function isFieldVisible( ): boolean { if (field.hidden) return false; return evalFieldPredicate(field.visibleWhen, values, true, previous ?? undefined, predicateScope, { - // Named for the canonical key even when the deprecated alias supplied the - // predicate: ADR-0089 is what this predicate is CALLED, and the warning - // prints the source text verbatim, which is what finds a hand-written - // `visibleOn`. A second stored key whose only job is to spell a deprecated - // name in a warning would be bookkeeping, not an honoured key. + // Named for the one key this row reads (ADR-0089, objectui#12654). context: `visibleWhen of field '${field.name}'`, }); } @@ -907,7 +897,7 @@ export function isFieldVisible( * Is this SECTION on screen, given the form's current state? (objectui#5627) * * The section-granularity twin of {@link isFieldVisible}, and deliberately the - * same three things: the canonical-first key read (done once, at build time — + * same three things: the one-key read (done once, at build time — * `RenderableSection.visibleWhen`), the canonical ENGINE * (`evalFieldPredicate`), and fail-OPEN with a one-time warning. A section that * cannot be evaluated must not vanish, for the field-level reason multiplied by diff --git a/apps/console/src/components/FormPage.visibleWhen.test.tsx b/apps/console/src/components/FormPage.visibleWhen.test.tsx index 7438e1b182..84f8da75e5 100644 --- a/apps/console/src/components/FormPage.visibleWhen.test.tsx +++ b/apps/console/src/components/FormPage.visibleWhen.test.tsx @@ -1,7 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * objectui#5594 — this renderer evaluates `visibleWhen` / `visibleOn`. + * objectui#5594 — this renderer evaluates `visibleWhen` (and, since + * objectui#12654, `visibleWhen` only: spec 18 retired `visibleOn`). * * ## The class this pins shut * @@ -43,6 +44,10 @@ * - "buildSections carries the predicate onto the row" -> `undefined` * - "isFieldVisible answers … in one verdict" -> no such export * + * (A historical reading. The two `visibleOn` rows named above were replaced by + * objectui#12654, which retires that spelling: their successor pins that the + * retired key gates nothing.) + * * The ONE that is green on both sides is the control that has to be: * * - "a field with NO predicate at all still renders" — without it, every @@ -67,7 +72,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { buildSections, FormPage, isFieldVisible } from './FormPage'; @@ -247,28 +252,18 @@ describe('objectui#5594 — FormPage evaluates conditional field visibility', () expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument(); }); - it('honours the deprecated ADR-0089 alias `visibleOn`', async () => { - // The spec normaliser rewrites the alias, so a spec-served view never - // carries it — but hand-written layouts still do, which is why both - // sibling readers keep reading it. + // objectui#12654: spec 18 retires the form field's `visibleOn` spelling + // (objectstack#23543 F4); the spec's parse refuses it by name and a stored + // row is rehydrated to `visibleWhen` before it is served. A raw view still + // carrying it gates nothing here — the narrowing, pinned beside the same + // predicate under the one key that is read. + it('does not read the retired `visibleOn` — the same predicate under `visibleWhen` does hide the field', async () => { renderInternal(['priority', { field: 'notes', visibleOn: "record.priority == 'urgent'" }]); await awaitForm(); - expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument(); - }); + expect(screen.getByLabelText('Notes')).toBeInTheDocument(); + cleanup(); - it('lets the canonical `visibleWhen` win when both spellings are authored', async () => { - // `visibleWhen ?? visibleOn` — the same precedence as - // `@object-ui/plugin-form` `sectionFields.ts` and app-shell's - // `readVisibility`. The two predicates disagree on purpose: only the - // canonical one's verdict can produce this outcome. - renderInternal([ - 'priority', - { - field: 'notes', - visibleWhen: "record.priority == 'urgent'", // false -> hidden - visibleOn: "record.priority == 'low'", // true -> would show - }, - ]); + renderInternal(['priority', { field: 'notes', visibleWhen: "record.priority == 'urgent'" }]); await awaitForm(); expect(screen.queryByLabelText('Notes')).not.toBeInTheDocument(); }); @@ -362,15 +357,15 @@ describe('objectui#5594 — FormPage evaluates conditional field visibility', () }); describe('objectui#5594 — the predicate reaches the row and the verdict', () => { - it('buildSections carries the predicate onto the renderable row, canonical first', () => { + it('buildSections carries the predicate onto the renderable row, from `visibleWhen` only', () => { const [section] = buildSections( { sections: [ { fields: [ { field: 'a', visibleWhen: "record.x == 1" }, - { field: 'b', visibleOn: { dialect: 'cel', source: 'record.x == 2' } }, - { field: 'c', visibleWhen: "record.x == 3", visibleOn: "record.x == 4" }, + { field: 'b', visibleWhen: { dialect: 'cel', source: 'record.x == 2' } }, + { field: 'c', visibleOn: "record.x == 4" }, { field: 'd' }, 'e', ], @@ -380,10 +375,11 @@ describe('objectui#5594 — the predicate reaches the row and the verdict', () = null, ); + // `c`: the retired spelling reaches no row (objectui#12654). expect(section.fields.map((f) => f.visibleWhen)).toEqual([ "record.x == 1", { dialect: 'cel', source: 'record.x == 2' }, - "record.x == 3", + undefined, undefined, undefined, ]); diff --git a/content/docs/guide/metadata-diagnostics.md b/content/docs/guide/metadata-diagnostics.md index ac850d976a..c38252aeba 100644 --- a/content/docs/guide/metadata-diagnostics.md +++ b/content/docs/guide/metadata-diagnostics.md @@ -197,8 +197,9 @@ a blank predicate at parse, as their protocol counterparts do. `visible`, and is only diagnosed. The same is now true of a **component node's own gate** — `visibleWhen` on a -page component (and its `visible` / `visibleOn` / `visibility` / `hidden` / -`hiddenOn` siblings), plus a `page:tabs` item's `visibleWhen`. These used to +page component (and its `visible` / `visibleOn` / `hidden` / `hiddenOn` +siblings; the page component's old `visibility` spelling is retired and not +read), plus a `page:tabs` item's `visibleWhen`. These used to report in a development build only, so a gate that stopped biting in production left nothing on the console at all. They now warn in **both** builds, with the node type, the node id, the gate key, the predicate source diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 7c296ea46a..9f9fc13cea 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -93,7 +93,7 @@ the spec. This page does not redeclare it, because a locally written | `validation` | `FieldValidationRules` | **an object keyed by rule name** — see below | | `condition` | `FieldCondition` | legacy `{ field, equals, notEquals, in }` matcher (`custom` is retired, objectui#7759: nothing ran it) | | `visibleWhen` / `readonlyWhen` / `requiredWhen` | `string \| { dialect?, source }` | CEL predicates over the live record, evaluated by `@objectstack/formula` — the same engine and dialect the server enforces. They fail open on screen; a `visibleWhen` that cannot be evaluated refuses the submit, naming the field and the rule | -| `visibleOn` | `string \| { dialect?, source }` | view-level visibility predicate (spec `FormField.visibleOn`) | +| `visibleOn` | `string \| { dialect?, source }` | view-level visibility predicate: the runtime slot a form view field's `visibleWhen` is carried into, ANDed with the field's own `visibleWhen` above. A form **view** field writes `visibleWhen`; the view's old `visibleOn` spelling is retired in `@objectstack/spec` 18 and is not read | | `dependsOn` | `DependsOnInput` | cascading parent(s): a bare name, a list of names, or `{ field, param }` entries | | `span` | `'auto' \| 'full'` | relative field width, independent of the column count (preferred) | | `colSpan` | `number` | legacy column span (1–4), clamped to the current column count | diff --git a/packages/plugin-form/src/sectionFields.blankGate-11262.test.ts b/packages/plugin-form/src/sectionFields.blankGate-11262.test.ts index fa8b8c8bf9..9650144b3b 100644 --- a/packages/plugin-form/src/sectionFields.blankGate-11262.test.ts +++ b/packages/plugin-form/src/sectionFields.blankGate-11262.test.ts @@ -22,8 +22,9 @@ * Every chain that reaches `attachVisibility` is pinned, because each reads a * different key: the object schema's `visibleOn` behind a name string (its * snake_case `visible_on` is not a chain since objectui#12242, pinned last), a - * spec form-view entry's `visibleWhen ?? visibleOn`, and an - * already-built runtime field's own `visibleOn`. The last one is the exception + * spec form-view entry's `visibleWhen` (its retired `visibleOn` is read by no + * chain since objectui#12654), and an already-built runtime field's own + * `visibleOn`. The last one is the exception * the card's reading did not draw: that field IS the output, so its blank is * not dropped at all. It reaches the form renderer, which evaluates it and * reports it (the `form.tsx` pin "a blank view-level visibleOn is a layout @@ -71,8 +72,6 @@ const CHAINS: Array ReturnTyp normalizeSectionField(name, ctxWith(name, { visibleOn: pred }))], ['a form-view entry’s visibleWhen', (pred, name) => normalizeSectionField({ field: name, visibleWhen: pred }, ctxWith(name))], - ['a form-view entry’s deprecated visibleOn', (pred, name) => - normalizeSectionField({ field: name, visibleOn: pred }, ctxWith(name))], ]; describe.each(CHAINS)('attachVisibility via %s (objectui#11262, ADR-0137 D4)', (_chain, build) => { diff --git a/packages/plugin-form/src/sectionFields.spec-parity.test.ts b/packages/plugin-form/src/sectionFields.spec-parity.test.ts index 8128e34eef..f900ab7f62 100644 --- a/packages/plugin-form/src/sectionFields.spec-parity.test.ts +++ b/packages/plugin-form/src/sectionFields.spec-parity.test.ts @@ -68,9 +68,15 @@ const norm = (def: Record) => /** * One row per spec FormFieldSchema key: sample authored value → where the * runtime FormField must carry it. Non-obvious destinations are the point: - * `helpText`→`description`, `readonly`→`disabled`, and BOTH visibility - * spellings→the view-level `visibleOn` slot (the runtime `visibleWhen` slot - * belongs to the object-level rule and must not be clobbered). + * `helpText`→`description`, `readonly`→`disabled`, and `visibleWhen`→the + * view-level `visibleOn` slot (the runtime `visibleWhen` slot belongs to the + * object-level rule and must not be clobbered). + * + * A RETIRED spec key keeps a row too, asserting the opposite: that it reaches + * no destination. The spec's own `visibleOn` is one (objectui#12654): spec 18 + * keeps it in the shape as a `retiredKey` tombstone, and the installed 18 line + * before that still declares it as a deprecated alias, so on both shapes it is + * a key the table must cover — by proving the normalizer drops it. */ const TABLE: Record) => any) => void> = { field: () => { @@ -114,8 +120,11 @@ const TABLE: Record) => any) => void> dependsOn: (f) => expect(f({ dependsOn: 'country' }).dependsOn).toBe('country'), visibleWhen: (f) => expect(f({ visibleWhen: 'record.a == 1' }).visibleOn).toBe('record.a == 1'), - visibleOn: (f) => - expect(f({ visibleOn: 'record.b == 2' }).visibleOn).toBe('record.b == 2'), + visibleOn: (f) => { + // Retired (objectui#12654): the spec spelling is not carried into the + // runtime view-level slot of the same name. The control is the row above. + expect(f({ visibleOn: 'record.b == 2' }).visibleOn).toBeUndefined(); + }, disclosure: (f) => expect(f({ disclosure: 'popover' }).disclosure).toBe('popover'), }; @@ -139,10 +148,24 @@ const EXEMPT: Record = { */ const GA_PENDING_EXEMPT = ['publicPicker']; +/** The authoring shape of a spec object schema, through a transform pipe if there is one. */ +function shapeOf(schema: unknown): Record { + let node = schema as { shape?: Record; _zod: { def: { type: string; in?: unknown } } }; + for (let depth = 0; depth < 4 && !node.shape; depth++) { + if (node._zod.def.type !== 'pipe') break; + node = node._zod.def.in as typeof node; + } + if (!node.shape) throw new Error('could not reach the shape of the installed spec FormFieldSchema'); + return node.shape; +} + describe('normalizeSectionField covers the spec FormFieldSchema key set', () => { - // `.strict().transform()` wraps the object in a ZodPipe; `.in` is the - // strict object carrying the authoring shape. - const specKeys = Object.keys((SpecFormFieldSchema as any).in.shape).sort(); + // Through the 18 pre-releases `.strict().transform()` wrapped the object in + // a ZodPipe whose `.in` carried the authoring shape; spec 18 drops the + // transform and the schema IS the closed object (objectstack#23543 F4). Walk + // to the shape on either form, so the next spec bump does not turn this gate + // red for a reason that is not a key change (objectui#12654). + const specKeys = Object.keys(shapeOf(SpecFormFieldSchema)).sort(); /** A GA-pending exemption the installed spec does not declare yet. */ const isDormant = (key: string) => GA_PENDING_EXEMPT.includes(key) && !specKeys.includes(key); diff --git a/packages/plugin-form/src/sectionFields.test.ts b/packages/plugin-form/src/sectionFields.test.ts index b289b69fdf..05cd3be63d 100644 --- a/packages/plugin-form/src/sectionFields.test.ts +++ b/packages/plugin-form/src/sectionFields.test.ts @@ -80,21 +80,23 @@ describe('normalizeSectionField', () => { expect(f.name).toBe('ghost'); // never undefined → no `.split` crash }); - // View-level conditional visibility (#2212): the spec `P`-template ships - // `visibleOn` as an Expression object `{ dialect: 'cel', source }`. It must - // survive normalization verbatim so the form renderer can evaluate it with - // the canonical engine — the old code only accepted a bare string, and even - // then attached a dead `visible()` closure instead. - it('carries a `{ dialect, source }` visibleOn expression through (spec shape, #2212)', () => { + // View-level conditional visibility (#2212): the spec field's predicate may + // be an Expression object `{ dialect: 'cel', source }`. It must survive + // normalization verbatim — into the runtime field's view-level `visibleOn` + // slot — so the form renderer can evaluate it with the canonical engine; the + // old code only accepted a bare string, and even then attached a dead + // `visible()` closure instead. (#2212's spec spelling was `visibleOn`; + // spec 18 retired it for `visibleWhen`, objectui#12654.) + it('carries a `{ dialect, source }` view-level predicate through (spec shape, #2212)', () => { const expr = { dialect: 'cel', source: "record.priority == 'urgent'" }; - const f = normalizeSectionField({ field: 'name', visibleOn: expr }, ctx); + const f = normalizeSectionField({ field: 'name', visibleWhen: expr }, ctx); expect((f as any).visibleOn).toEqual(expr); expect((f as any).visible).toBeUndefined(); // no dead closure }); - it('carries a bare-string visibleOn through (#2212)', () => { + it('carries a bare-string view-level predicate through (#2212)', () => { const f = normalizeSectionField( - { field: 'name', visibleOn: "record.priority == 'urgent'" }, + { field: 'name', visibleWhen: "record.priority == 'urgent'" }, ctx, ); expect((f as any).visibleOn).toBe("record.priority == 'urgent'"); @@ -147,14 +149,16 @@ describe('normalizeSectionField', () => { expect(f.visibleOn).toBe('record.amount > 0'); // view predicate in the view slot }); - it('prefers the canonical spelling when both visibleWhen and deprecated visibleOn are authored', () => { - // `saveMeta` persists verbatim, so served metadata can carry either (or, - // after a partial migration, both). Canonical wins. - const f = normalizeSectionField( - { field: 'name', visibleWhen: "record.a == 1", visibleOn: "record.b == 2" }, - ctx, - ) as any; - expect(f.visibleOn).toBe("record.a == 1"); + // objectui#12654: spec 18 retires the spec field's `visibleOn` spelling + // (objectstack#23543 F4) — refused by name at every parsing door, and a + // stored row is rehydrated to `visibleWhen` before it is served. A raw spec + // field still carrying it reaches no gate; the same predicate under + // `visibleWhen` does. The pair is the narrowing. + it('does not read the retired spec-field `visibleOn` — the same predicate under `visibleWhen` is carried', () => { + const retired = normalizeSectionField({ field: 'name', visibleOn: "record.b == 2" }, ctx) as any; + expect(retired.visibleOn).toBeUndefined(); + const canonical = normalizeSectionField({ field: 'name', visibleWhen: "record.b == 2" }, ctx) as any; + expect(canonical.visibleOn).toBe("record.b == 2"); }); it('carries a view-level dependsOn (spec cascading declaration)', () => { diff --git a/packages/plugin-form/src/sectionFields.ts b/packages/plugin-form/src/sectionFields.ts index 6a725f0095..92485f64f3 100644 --- a/packages/plugin-form/src/sectionFields.ts +++ b/packages/plugin-form/src/sectionFields.ts @@ -463,12 +463,16 @@ function resolveSectionEntry( // View-level visibility predicate. ADR-0089 renamed it to `visibleWhen` — // which collides with the runtime slot holding the OBJECT-level rule (copied // from the object schema in `fromObjectSchema`). Route the view predicate - // into the view-level slot (`visibleOn`) instead: the renderer evaluates the - // two slots independently and ANDs them, so layering is preserved and the - // object rule is never clobbered. Canonical spelling wins over the - // deprecated one when both are authored (`saveMeta` persists verbatim, so - // served metadata can carry either). - return attachVisibility(base as FormField, fd.visibleWhen ?? fd.visibleOn); + // into the view-level slot (`visibleOn`, the RUNTIME FormField's own slot) + // instead: the renderer evaluates the two slots independently and ANDs them, + // so layering is preserved and the object rule is never clobbered. + // + // Read from the spec field's `visibleWhen` ONLY (objectui#12654): spec 18 + // retires the spec field's old `visibleOn` spelling as a `retiredKey` + // tombstone (objectstack#23543 F4) — every parsing door refuses it by name + // and a stored row is rehydrated to `visibleWhen` before it is served — so a + // raw spec field still carrying it is not folded back here. + return attachVisibility(base as FormField, fd.visibleWhen); } /** diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index e00718a537..74db94197f 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -35,7 +35,7 @@ import { PARAMS_KEY, isParamsBag, mapParamsLeaves } from './utils/paramsBag.js'; import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js'; import { reportDroppedPropsBag, reportRefusedPropsPredicate } from './utils/propsBagDiagnostic.js'; import { reportRefusedDataPropSpread } from './utils/refusedDataPropDiagnostic.js'; -import { expressionBindableTextKeysFor, type PageComponent } from '@objectstack/spec/ui'; +import { expressionBindableTextKeysFor } from '@objectstack/spec/ui'; import { reportUnresolvableVisibilityPredicate, reportAdapterOnlyDataPredicate, @@ -157,7 +157,7 @@ interface InterpreterMarkers { } /** - * The two AUTHORED keys the working copy names explicitly, each on a ruling + * The AUTHORED key the working copy names explicitly, on a ruling * (objectui#11349's per-key verdicts). Every other authored key the memo * reads (`properties`, `content`, `params`, the text keys, `responsiveStyles`, * …) stays `unknown` and narrows at its read site. @@ -175,20 +175,11 @@ interface WorkingCopyAuthoredKeys { * `isConfigBag` first. Naming it here neither retires it nor widens it. */ props?: unknown; - /** - * `@objectstack/spec`'s DEPRECATED alias of `visibleWhen`, typed BY - * REFERENCE to the declaration on `PageComponentSchema`, which says it is - * "Accepted and normalized to `visibleWhen` at parse". A spec-parsed page - * never carries it. The `shouldHide` chain reads it for raw, un-normalized - * metadata, on its `@deprecated ADR-0089` leg. - * - * It is not narrowed at the read. The leg is chosen by `!== undefined`, and - * `evaluateCondition` answers every runtime shape. A narrowing guard would - * therefore move the verdict for an off-spec value. The type is the spec's. - * `BaseSchema` does not declare the key: objectui#10872 leaves the spec's - * other node-level keys off the authoring face until a producer needs them. - */ - visibility?: PageComponent['visibility']; + // ⛔ No `visibility`. The page component's old spelling of `visibleWhen` is + // a `retiredKey` tombstone in `@objectstack/spec` 18 (objectstack#23543 F4): + // every parsing door refuses it by name and stored rows are rehydrated to + // `visibleWhen`, so this interpreter reads `visibleWhen` only and a raw + // node still carrying `visibility` gates nothing (objectui#12654). } /** @@ -208,7 +199,7 @@ interface WorkingCopyAuthoredKeys { * * 1. the members `BaseSchema` DECLARES ({@link DeclaredMembersOf}); * 2. {@link InterpreterMarkers}, the two flags this component writes; - * 3. {@link WorkingCopyAuthoredKeys}, the two authored keys a ruling names; + * 3. {@link WorkingCopyAuthoredKeys}, the authored key a ruling names; * 4. `Record` for every other key. A read of a key * `BaseSchema` does not declare is `unknown` and must NARROW before use. * That is stricter than the `any` this type replaces. @@ -362,11 +353,11 @@ function propsWithoutCanonicalKeys( * means *hidden* and the wider `!== undefined` test would vanish a node for * `hidden: ''` (objectui#3955). See {@link winningVisibilityKey}. */ -const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn', 'visibility'] as const; +const VISIBILITY_SHOW_KEYS = ['visibleWhen', 'visible', 'visibleOn'] as const; const VISIBILITY_HIDE_KEYS = ['hidden', 'hiddenOn'] as const; /** - * The six legs, as ONE closed type — so a seventh cannot be added to the chain + * The five legs, as ONE closed type — so a seventh cannot be added to the chain * without also being classified below. */ type VisibilityChainKey = @@ -374,7 +365,7 @@ type VisibilityChainKey = | (typeof VISIBILITY_HIDE_KEYS)[number]; /** - * The same six legs as a lookup, so the config-bag evaluation loops below can + * The same five legs as a lookup, so the config-bag evaluation loops below can * ask "is this key a visibility predicate?" off the SAME declaration the chain * is built from rather than a second list that can drift from it. * @@ -516,7 +507,7 @@ const isCelEnvelope = (value: unknown): boolean => * `has(record.x) && …` throws — and the two polarities of the chain then fail * in OPPOSITE directions, both silently: * - * * on the four SHOW legs the fail-soft `true` is negated to "do not hide", + * * on the three SHOW legs the fail-soft `true` is negated to "do not hide", * so a gate authored to conceal renders on every row — measured in a real * browser against a real app instance (the card), where a duplicate-lead * banner showed on 6 of 6 leads whose field was `null`; @@ -745,7 +736,7 @@ export function evaluateConfigBagInScope( * ## The defect this closes * * `evaluateCondition` answers an unevaluable predicate with `true` on every - * path. On the four SHOW legs the chain negates that answer, so the node is + * path. On the three SHOW legs the chain negates that answer, so the node is * SHOWN and the reporter's "the gate did NOT bite" is true. On the two HIDE * legs the chain returns it UN-negated (see `shouldHide` below), so the same * `true` sets `_hidden` and this component returns `null`: the gate bit, and @@ -1902,12 +1893,13 @@ export const SchemaRenderer: ForwardRefExoticComponent< propsWithoutCanonicalKeys(newSchema.props, newSchema.properties), ); - // Evaluate visibility: visibleWhen / visible / visibleOn / visibility / hidden / hiddenOn + // Evaluate visibility: visibleWhen / visible / visibleOn / hidden / hiddenOn const shouldHide = (() => { // `visibleWhen` is the single canonical conditional-visibility predicate // across every layer since ADR-0089 (show-when-truthy), and it is the one // this shape DECLARES: `PageComponentSchema` is a closed object that - // declares `visibleWhen` (+ the deprecated `visibility`) and REFUSES a + // declares `visibleWhen` (its old `visibility` spelling is retired, see + // `WorkingCopyAuthoredKeys`) and REFUSES a // node-level `visible` outright, with the prescription "move it up one // level to the component node's own `visibleWhen` … inside `properties` // it is hoisted onto the node by the renderer but evaluated by nothing" @@ -1927,23 +1919,17 @@ export const SchemaRenderer: ForwardRefExoticComponent< return !evaluateVisibilityPredicate(newSchema.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 + // the landing spot of a hoisted `properties.visible`. Kept ABOVE + // `BaseSchema`'s deprecated `visibleOn`: re-ranking it would move // verdicts for raw metadata that objectui#5454 did not rule on. if (newSchema.visible !== undefined) { return !evaluateVisibilityPredicate(newSchema.visible, 'visible'); } - // @deprecated ADR-0089 → `visibleWhen`. Defensive read for raw / - // un-normalized metadata reaching the renderer. + // `BaseSchema.visibleOn`, @deprecated ADR-0089 → `visibleWhen`. + // objectui's own node key; the spec's page component never declared it. if (newSchema.visibleOn !== undefined) { return !evaluateVisibilityPredicate(newSchema.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'); - } // Ask "is a `hidden` gate DECLARED?" — not "is the key present?" // (objectui#3955). These two legs are the only ones in this chain whose // verdict is NOT negated, so the evaluator's single default for "there is @@ -1989,7 +1975,7 @@ export const SchemaRenderer: ForwardRefExoticComponent< // as an internal flag either: `_disabled` is forwarded below as a real // `disabled` prop. // - // The `visible` / `visibleWhen` / `visibleOn` / `visibility` legs above keep + // The `visible` / `visibleWhen` / `visibleOn` legs above keep // `!== undefined` deliberately: their `true` is NEGATED, so an empty predicate // already lands on "shown", which is what "no gate" means anyway, and // narrowing them would change ALIAS PRECEDENCE rather than fix anything. The @@ -2188,7 +2174,6 @@ export const SchemaRenderer: ForwardRefExoticComponent< visible: _visible, visibleWhen: _visibleWhen, visibleOn: _visibleOn, - visibility: _visibility, hidden: _hidden, hiddenOn: _hiddenOn, disabled: _disabled, diff --git a/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx index a92ff3b891..1939c89c8f 100644 --- a/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.concealmentGateFaultDiagnostic.test.tsx @@ -14,7 +14,8 @@ * * `evaluateCondition` answers an unevaluable predicate with `true` on every one * of its paths. `SchemaRenderer`'s visibility chain NEGATES that answer on four - * of its six legs (`visibleWhen` / `visible` / `visibleOn` / `visibility`), so + * of its six legs (`visibleWhen` / `visible` / `visibleOn` / `visibility`; the + * last was retired by objectui#12654, leaving three of five), so * there the fail-soft default SHOWS the node and the reporter's sentence — "the * gate did NOT bite" — is exactly right. The chain returns it UN-negated on the * other two (`hidden` / `hiddenOn`): the same `true` sets `_hidden` and @@ -158,7 +159,6 @@ const FAULT_HIDDENON_DEV = 'devbadroot6503hiddenon.locked == true'; const FAULT_VISIBLEWHEN_DEV = 'devbadroot6503vw.locked == true'; const FAULT_VISIBLE_DEV = 'devbadroot6503v.locked == true'; const FAULT_VISIBLEON_DEV = 'devbadroot6503von.locked == true'; -const FAULT_VISIBILITY_DEV = 'devbadroot6503vy.locked == true'; const FAULT_DISABLED_DEV = 'devbadroot6503dis.locked == true'; const FAULT_TEMPLATE_HIDDEN = '${tmplbadroot6503.locked == true}'; const FAULT_CEL_HIDDEN = { dialect: 'cel', source: 'record.bad6503(' }; @@ -370,10 +370,10 @@ describe('#6503 group 1 — a faulting `hidden` says the node VANISHED, and it d }); /* -------------------------------------------------------------------------- * - * Group 2 — THE CONTROL. The four negated legs must be UNCHANGED. + * Group 2 — THE CONTROL. The negated legs must be UNCHANGED. * -------------------------------------------------------------------------- */ -describe('#6503 group 2 — the four NEGATED legs keep their sentence, byte for byte', () => { +describe('#6503 group 2 — the NEGATED legs keep their sentence, byte for byte', () => { // Each leg individually, not "one of them": `shouldHide` is an early-return // chain, so a single mount can only ever exercise the FIRST declared leg. A // loop over one schema would have measured `visibleWhen` four times. @@ -381,7 +381,6 @@ describe('#6503 group 2 — the four NEGATED legs keep their sentence, byte for ['visibleWhen', FAULT_VISIBLEWHEN_DEV], ['visible', FAULT_VISIBLE_DEV], ['visibleOn', FAULT_VISIBLEON_DEV], - ['visibility', FAULT_VISIBILITY_DEV], ]; for (const [key, source] of NEGATED) { @@ -389,7 +388,7 @@ describe('#6503 group 2 — the four NEGATED legs keep their sentence, byte for await inProduction((mount) => { const warn = spyWarn(); mount([{ id: 'n1', [key]: source }]); - // The fail-soft answer these four legs have always given. + // The fail-soft answer these legs have always given. expect(shownCount()).toBe(1); const lines = reports(warn); expect(lines).toHaveLength(1); diff --git a/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx index 3843d146e0..66f24bb37c 100644 --- a/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.disabledGateFaultDiagnostic.test.tsx @@ -14,7 +14,8 @@ * ## The silence this closes * * Six legs of `SchemaRenderer`'s visibility chain (`visibleWhen`, `visible`, - * `visibleOn`, `visibility`, `hidden`, `hiddenOn`) route through + * `visibleOn`, `visibility`, `hidden`, `hiddenOn`; `visibility` was retired by + * objectui#12654) route through * `evaluateVisibilityPredicate` and report. These two called * `evaluateCondition` BARE — the only uninstrumented predicate pair in the * file — so a faulting `disabled` predicate had never been reported in any diff --git a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx index 8293db35a7..3b77b9145f 100644 --- a/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx @@ -21,22 +21,23 @@ import { SchemaRenderer } from '../SchemaRenderer'; // evaluates it through the same `evaluateCondition`, in the evaluation memo of // `SchemaRenderer.tsx`, and the `disabledOn?: string` sibling exists for // the same reason), and the two casts are gone. The `BaseSchema` import went -// with them. It is back only to type the deprecated-`visibility` probe node -// below (objectui#11349), not for a cast. +// with them. It is back only to type the retired-`visibility` probe node +// below (objectui#11349, objectui#12654), not for a cast. import { SchemaRendererContext } from '../context/SchemaRendererContext'; import type { BaseSchema, DataSource } from '@object-ui/types'; -import type { PageComponent } from '@objectstack/spec/ui'; /** - * A node authoring the spec's DEPRECATED `visibility` alias (objectui#11349). - * `BaseSchema` does not declare the key. `@objectstack/spec` declares it on - * `PageComponentSchema`, and the alias case below is typed by reference to - * that row. It is checked against this node, not against `BaseSchema`, so the - * key stays checked now that objectui#8347 has removed `BaseSchema`'s index - * signature. + * A RAW node still carrying the page component's `visibility` key, the old + * spelling of `visibleWhen` (objectui#11349). `@objectstack/spec` 18 retires + * it as a `retiredKey` tombstone (objectstack#23543 F4), so it is no longer + * typed by reference to `PageComponent` — its type there is `never` — and the + * probe declares the key itself, as raw data that bypassed every parsing door + * would carry it. The case below pins that the renderer does NOT read it + * (objectui#12654). Declared on this test's own registered node type, not on + * `BaseSchema`, which has no index signature (objectui#8347). */ -type DeprecatedVisibilityNode = BaseSchema & Pick & { type: 'test-component' }; -const deprecatedVisibilityNode = (schema: DeprecatedVisibilityNode): DeprecatedVisibilityNode => schema; +type RetiredVisibilityNode = BaseSchema & { type: 'test-component'; visibility?: string }; +const retiredVisibilityNode = (schema: RetiredVisibilityNode): RetiredVisibilityNode => schema; import { PredicateScopeProvider } from '../hooks/useExpression'; /** @@ -47,7 +48,7 @@ import { PredicateScopeProvider } from '../hooks/useExpression'; */ declare module '@object-ui/types' { interface CustomNodeRegistry { - 'test-component': DeprecatedVisibilityNode; + 'test-component': RetiredVisibilityNode; } } @@ -139,8 +140,9 @@ describe('SchemaRenderer Expression Integration', () => { }); }); - // ADR-0089 — `visibleWhen` is the canonical conditional-visibility predicate; - // `visibleOn` / `visibility` remain as deprecated aliases the renderer still reads. + // ADR-0089 — `visibleWhen` is the canonical conditional-visibility predicate. + // The page component's `visibility` spelling is retired (objectui#12654). + // `BaseSchema`'s own deprecated `visibleOn` is a different key, not covered here. describe('visibleWhen (ADR-0089 canonical)', () => { it('shows when the visibleWhen predicate is truthy', () => { render( @@ -164,15 +166,17 @@ describe('SchemaRenderer Expression Integration', () => { expect(container.innerHTML).toBe(''); }); - it('still honors the deprecated `visibility` alias', () => { - const { container } = render( + // The narrowing (objectui#12654): the predicate that hides the node under + // `visibleWhen` (the case above) gates NOTHING under the retired key. + it('does not read the retired `visibility` key — the node stays on screen', () => { + render( - + ); - expect(container.innerHTML).toBe(''); + expect(screen.getByTestId('test-component')).toBeInTheDocument(); }); }); diff --git a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx index 2612361e43..f2ace5c240 100644 --- a/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeConfigBag.test.tsx @@ -135,7 +135,7 @@ describe('#9100 — a CEL envelope in the config bag reaches the CEL engine', () // A pair of EQUAL verdicts is the signature of a gate that was never // consulted, whichever way it landed — which is exactly what the broken // tree produced on every row below. - it.each(['visibleWhen', 'visible', 'visibleOn', 'visibility'])( + it.each(['visibleWhen', 'visible', 'visibleOn'])( 'properties.%s (SHOW polarity): holds renders, fails hides', (key) => { expect(pair(key)).toEqual({ holds: true, fails: false }); diff --git a/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx b/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx index c7556f553a..9a0d34127d 100644 --- a/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.propsBagLoudRefusal.test.tsx @@ -155,7 +155,6 @@ describe('objectui#9108 - the alias still gates NOTHING (the refused arm)', () = ['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 }); @@ -202,7 +201,6 @@ describe('objectui#9108 - and it is refused BY NAME, loudly', () => { ['visibleWhen'], ['visible'], ['visibleOn'], - ['visibility'], ['hidden'], ['hiddenOn'], ['disabled'], diff --git a/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx b/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx index 8b10d7932e..b1a77717d6 100644 --- a/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.unevaluatedExpressionDiagnostic.test.tsx @@ -202,7 +202,6 @@ describe('SchemaRenderer — unevaluated `${…}` diagnostic (objectui#4795)', ( ['visible', '${data.n > 0}'], ['visibleWhen', '${data.n > 0}'], ['visibleOn', '${data.n > 0}'], - ['visibility', '${data.n > 0}'], ['hiddenOn', '${data.n < 0}'], ['disabled', '${data.n < 0}'], ['disabledOn', '${data.n < 0}'], diff --git a/packages/react/src/__tests__/SchemaRenderer.visibleWhenRecordBinding.test.tsx b/packages/react/src/__tests__/SchemaRenderer.visibleWhenRecordBinding.test.tsx index 9882defbac..9f5a457c12 100644 --- a/packages/react/src/__tests__/SchemaRenderer.visibleWhenRecordBinding.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.visibleWhenRecordBinding.test.tsx @@ -249,15 +249,12 @@ describe('#5454 leg 2 — a declared `visibleWhen` outranks a hoisted `propertie expect(shown()).toBe(true); }); - it('`visible` still outranks the two DEPRECATED aliases — only `visibleWhen` moved', () => { - // Deliberately unmoved: `visibleOn` / `visibility` normalize INTO - // `visibleWhen` at parse, so a spec-parsed page never reaches them, and - // re-ranking them would move verdicts #5454 did not rule on. + it('`visible` still outranks the DEPRECATED `visibleOn` — only `visibleWhen` moved', () => { + // Deliberately unmoved: re-ranking it would move verdicts #5454 did not + // rule on. (The page component's `visibility` spelling is retired and no + // longer a leg at all, objectui#12654.) mount({ visible: true, visibleOn: 'false' }, DONE); expect(shown()).toBe(true); - cleanup(); - mount({ visible: true, visibility: 'false' }, DONE); - expect(shown()).toBe(true); }); }); diff --git a/packages/react/src/utils/visibilityDiagnostic.ts b/packages/react/src/utils/visibilityDiagnostic.ts index 0267e759af..6290e2ef77 100644 --- a/packages/react/src/utils/visibilityDiagnostic.ts +++ b/packages/react/src/utils/visibilityDiagnostic.ts @@ -15,8 +15,8 @@ * ## The defect this names * * `SchemaRenderer`'s visibility chain is FAIL-SOFT: `evaluateCondition` answers - * an unevaluable predicate with `true`. On the four negated legs (`visible` / - * `visibleWhen` / `visibleOn` / `visibility`) that `true` means SHOWN — so "this + * an unevaluable predicate with `true`. On the negated legs (`visible` / + * `visibleWhen` / `visibleOn`) that `true` means SHOWN — so "this * predicate is broken" and "this predicate said yes" reach the screen as the * same pixel. An author who mistyped a root, or who wrote a `record.*` gate * before objectui#5454 bound the row, saw their block render and had no signal @@ -180,7 +180,7 @@ const SCOPE_TIER_ADVICE: Record = { * to the node is not a property of the predicate - it is a property of the gate * it was authored on, and the consequence paragraph has to say which: * - * `'visibility'` - the visibility chain. Its `true` means SHOWN on the four + * `'visibility'` - the visibility chain. Its `true` means SHOWN on the * negated legs, so the safe default does NOT bite: a predicate that could not be * evaluated reads on screen exactly like one that said yes, which is why the * console line is the only signal an author gets. From 02419233982dea390d7be36b3b0290e5a4642f43 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:01:30 +0000 Subject: [PATCH 2/3] docs(changeset): spell the predicate placeholder without angle brackets Claude-Session: https://claude.ai/code/session_01SdA7b5ZrdDDBDKG48XRqy7 Co-authored-by: Claude --- .changeset/12654-plugin-form-visibleon-retired.md | 2 +- .changeset/12654-react-visibility-retired.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/12654-plugin-form-visibleon-retired.md b/.changeset/12654-plugin-form-visibleon-retired.md index b751ac5573..c30bc92cf8 100644 --- a/.changeset/12654-plugin-form-visibleon-retired.md +++ b/.changeset/12654-plugin-form-visibleon-retired.md @@ -4,6 +4,6 @@ A form view field's `visibleOn` is no longer read when sections are normalized (`normalizeSectionField`, used by `ObjectForm`, `ModalForm` and the record form page) (objectui#12654). `@objectstack/spec` 18 retires the form view field's `visibleOn` as a `retiredKey` tombstone naming `visibleWhen` (objectstack#23543 F4), so the field's `visibleWhen` is the one view-level predicate carried onto the runtime field. -What a raw schema loses: a form view section entry `{ field: 'x', visibleOn: '' }` that did not pass through the spec's parse no longer hides the field; write `visibleWhen`. A runtime `FormField` (an inline field object with its own `name`) keeps its `visibleOn` slot: that is the runtime field's view-level gate, a different key, and it is unchanged. +What a raw schema loses: a form view section entry `{ field: 'x', visibleOn: 'PREDICATE' }` that did not pass through the spec's parse no longer hides the field; write `visibleWhen`. A runtime `FormField` (an inline field object with its own `name`) keeps its `visibleOn` slot: that is the runtime field's view-level gate, a different key, and it is unchanged. What is unaffected: form views served by an ObjectStack runtime. They already arrive with `visibleWhen`: the spec's parse folds the old key through the 18 pre-releases, and spec 18's stored-row rehydration rewrites a row stored with it. diff --git a/.changeset/12654-react-visibility-retired.md b/.changeset/12654-react-visibility-retired.md index f6531bad68..f73e379010 100644 --- a/.changeset/12654-react-visibility-retired.md +++ b/.changeset/12654-react-visibility-retired.md @@ -4,6 +4,6 @@ `SchemaRenderer` no longer reads a page component's `visibility` key (objectui#12654). `@objectstack/spec` 18 retires it as a `retiredKey` tombstone naming `visibleWhen` (objectstack#23543 F4), so `visibleWhen` is the one conditional-visibility key the renderer reads for it. -What a raw schema loses: a node handed straight to `SchemaRenderer`, without passing through the spec's parse, that carries only `visibility: ''` is no longer hidden when the predicate is false; it now draws, and the `visibility` value is no longer stripped before the component's props. Write `visibleWhen` instead. The `SchemaRenderer` working-copy type no longer declares `visibility`. +What a raw schema loses: a node handed straight to `SchemaRenderer`, without passing through the spec's parse, that carries only `visibility: 'PREDICATE'` is no longer hidden when the predicate is false; it now draws, and the `visibility` value is no longer stripped before the component's props. Write `visibleWhen` instead. The `SchemaRenderer` working-copy type no longer declares `visibility`. What is unaffected: metadata served by an ObjectStack runtime. A served page row already reaches the renderer with `visibleWhen`: the spec's parse folds the old key through the 18 pre-releases, and spec 18's stored-row rehydration rewrites a row stored with it. `BaseSchema`'s own deprecated `visibleOn` is a different key and is still read. From 688d9bacea293c75f1a80aac0163520d96212f65 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:59:59 +0000 Subject: [PATCH 3/3] test(components,scripts): re-point the page-variables and carriage pins at the retired visibility leg page-variables: the retired `visibility` key (bare and envelope) gates nothing, and the same predicate under `visibleWhen` (bare and envelope) does. The envelope form keeps its gating coverage on `visibleWhen`. check-doc-expression-carriage: the renderer's VISIBILITY_* arrays now hold five keys and the gate derives seven condition keys. Claude-Session: https://claude.ai/code/session_01SdA7b5ZrdDDBDKG48XRqy7 Co-authored-by: Claude --- .../src/__tests__/page-variables.test.tsx | 41 +++++++++++++------ .../check-doc-expression-carriage.test.ts | 5 ++- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/components/src/__tests__/page-variables.test.tsx b/packages/components/src/__tests__/page-variables.test.tsx index 8b8a1de082..530477eaba 100644 --- a/packages/components/src/__tests__/page-variables.test.tsx +++ b/packages/components/src/__tests__/page-variables.test.tsx @@ -86,7 +86,7 @@ describe('SchemaRenderer page-variable visibility', () => { ); } - function renderGated(predicateKey: 'visible' | 'visibility') { + function renderGated(predicateKey: 'visible' | 'visibleWhen' | 'visibility') { return render( @@ -111,8 +111,8 @@ describe('SchemaRenderer page-variable visibility', () => { expect(getByText('NOW VISIBLE')).toBeTruthy(); }); - it('also gates on the spec-canonical `visibility` predicate', () => { - const { queryByText, getByText } = renderGated('visibility'); + it('also gates on the spec-canonical `visibleWhen` predicate', () => { + const { queryByText, getByText } = renderGated('visibleWhen'); expect(queryByText('NOW VISIBLE')).toBeNull(); act(() => { fireEvent.click(getByText('write')); @@ -120,26 +120,38 @@ describe('SchemaRenderer page-variable visibility', () => { expect(getByText('NOW VISIBLE')).toBeTruthy(); }); - it('gates on a `visibility` Expression envelope { dialect, source } (the spec-bridge form)', () => { - // definePage normalizes a bare predicate string into { dialect: 'cel', - // source } and the spec→node bridge carries that envelope onto the node — - // this is the exact shape the showcase page produces at runtime. - const { queryByText, getByText } = render( + // objectui#12654: `@objectstack/spec` 18 retires the page component's + // `visibility` key (a `retiredKey` tombstone naming `visibleWhen`). The same + // predicate that gates under `visibleWhen` above gates NOTHING under it. + it('does not gate on the retired `visibility` key', () => { + const { getByText } = renderGated('visibility'); + expect(getByText('NOW VISIBLE')).toBeTruthy(); + }); + + // definePage normalizes a bare predicate string into { dialect: 'cel', + // source } and the spec→node bridge carries that envelope onto the node — + // this is the exact shape the showcase page produces at runtime. + function renderEnvelopeGated(predicateKey: 'visibleWhen' | 'visibility') { + return render( , ); + } + + it('gates on a `visibleWhen` Expression envelope { dialect, source } (the spec-bridge form)', () => { + const { queryByText, getByText } = renderEnvelopeGated('visibleWhen'); expect(queryByText('NOW VISIBLE')).toBeNull(); act(() => { fireEvent.click(getByText('write')); @@ -147,6 +159,11 @@ describe('SchemaRenderer page-variable visibility', () => { expect(getByText('NOW VISIBLE')).toBeTruthy(); }); + it('does not gate on a `visibility` Expression envelope — the retired key (objectui#12654)', () => { + const { getByText } = renderEnvelopeGated('visibility'); + expect(getByText('NOW VISIBLE')).toBeTruthy(); + }); + it('does not leak the `visibility` predicate string onto the DOM node', () => { const { container } = render( diff --git a/scripts/__tests__/check-doc-expression-carriage.test.ts b/scripts/__tests__/check-doc-expression-carriage.test.ts index c674c21953..c9202c6840 100644 --- a/scripts/__tests__/check-doc-expression-carriage.test.ts +++ b/scripts/__tests__/check-doc-expression-carriage.test.ts @@ -84,13 +84,14 @@ describe('check-doc-expression-carriage: the evaluated-channel universe is deriv return [...literal!.matchAll(/'([^']+)'/g)].map((m) => m[1]); }; const visibility = [...declared('VISIBILITY_SHOW_KEYS'), ...declared('VISIBILITY_HIDE_KEYS')]; - expect(visibility.length).toBe(6); + // Five: the page component's `visibility` leg was retired (objectui#12654). + expect(visibility.length).toBe(5); for (const key of visibility) expect(channels.conditions).toContain(key); // The enablement pair is not in either array — it routes through // `evaluateEnablementPredicate`, which is why the gate reads call sites at all. expect(channels.conditions).toContain('disabled'); expect(channels.conditions).toContain('disabledOn'); - expect(channels.conditions.length).toBe(8); + expect(channels.conditions.length).toBe(7); }); it('refuses to census against a guessed universe when the renderer is missing', () => {