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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/23036-visiblewhen-no-data-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': patch
---

fix(spec): a page component's `visibleWhen` describe no longer says the renderer binds `data` — the shipping renderer mounts no `data` root there (#23036)

Clause-②: no

The `.describe()` on `PageComponentSchema.visibleWhen` said "The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here", and the TSDoc above it said the same. The console's renderer stopped binding `data` on this surface (objectui ruling on the renderer seam's data scope, 2026-09-13): a `data.*` read there now faults, and a faulting `visibleWhen` renders the component, so a gate written from the old text, such as `data.total > 0`, shows the component whether it is true or false.

The describe now names what the renderer mounts beyond the contract-bound roots (`features` and `os.user`), states that no `data` root is mounted, and says the one declared `data` read on this surface is the PLANNED API binding's (`dataSource: { provider: 'api' }`), once that binding's renderer ships. The `page:tabs` item-level `visibleWhen`, a different evaluator that binds `data` to the record row, is still called out. Nothing an author can write changes: the accept set is the same, and only the description text and the generated reference page move.

**If you wrote a page component gate on `data.*`:** gate on the page's row as `record.*`, or on page state as `page.<var>`.
4 changes: 2 additions & 2 deletions content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ View filter rule
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
| **className** | `string` | optional | CSS class names |
| **responsiveStyles** | `{ large?: Record<string, string \| number>; medium?: Record<string, string \| number>; small?: Record<string, string \| number>; xsmall?: Record<string, string \| number> }` | optional | Per-breakpoint scoped style maps (ADR-0065) |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" |
| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint |
| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
Expand Down Expand Up @@ -440,7 +440,7 @@ Print declaration: paper size, orientation, margins, the running header and foot
| **style** | `Record<string, string>` | optional | Inline styles or utility classes |
| **className** | `string` | optional | CSS class names |
| **responsiveStyles** | `{ large?: Record<string, string \| number>; medium?: Record<string, string \| number>; small?: Record<string, string \| number>; xsmall?: Record<string, string \| number> }` | optional | Per-breakpoint scoped style maps (ADR-0065) |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). ⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead. e.g. "page.selectedProjectId != ''" |
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — component rendered only when TRUE. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user` — one object, three spellings), and page state as `page.<var>`. The shipping renderer additionally mounts `features` and `os.user` — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). It mounts NO `data` root here: a `data.*` read faults and a faulting predicate renders the component, so such a gate never gates; the page's row is `record`. `data` is declared only for an element whose `dataSource` is the PLANNED API binding (`provider: 'api'`), which reads its bound value as `data` once that binding's renderer ships. ⚠️ A `page:tabs` item `visibleWhen` is a different evaluator: it binds `data` to the record ROW. e.g. "page.selectedProjectId != ''" |
| **visibility** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. |
| **dataSource** | `{ object: string; view?: string; filter?: object[]; sort?: object[]; … } \| { provider: 'api'; read: object; path?: string }` | optional | Per-element data binding: an object binding `{ object, view?, filter?, sort?, limit? }` that overrides the page object, or an API binding `{ provider: 'api', read: { url }, path? }` that reads one same-origin endpoint |
| **responsive** | `never` | optional | [REMOVED] `page.components[].responsive` was removed in @objectstack/spec 17 (ADR-0049 D2) — no renderer ever read it, so per-breakpoint layout overrides (columns/order/visibility) parsed, validated, and then did nothing. Delete the key. For breakpoint behaviour that IS applied, use the sibling `responsiveStyles` (ADR-0065) — per-breakpoint CSS maps compiled to id-scoped CSS at render, e.g. `responsiveStyles: { xsmall: { display: 'none' } }` to hide a component on the narrowest screens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/ui/expression-scope-app-root.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ describe('no UI prose face advertises `app` as an expression-scope root — the
expect(mounts).not.toMatch(/`app`/);

// Survival controls — deleting the token must not have taken the sentence with it.
// (`data` is no longer one of them: the renderer stopped mounting it too, and
// `expression-scope-data-root.pin.test.ts` holds that half.)
for (const root of SURVIVING_ROOTS) expect(mounts).toContain(root);
expect(mounts).toContain('`data`');
expect(mounts).toContain('NOT contract-guaranteed');

// Contract-bound roots are a different clause and are untouched.
Expand All @@ -117,7 +118,6 @@ describe('no UI prose face advertises `app` as an expression-scope root — the

expect(s).not.toMatch(/`app`/);
for (const root of SURVIVING_ROOTS) expect(s).toContain(root);
expect(s).toContain('binds `data`');
});

it('action.zod.ts — the param-level `visible` scope list', () => {
Expand Down
208 changes: 208 additions & 0 deletions packages/spec/src/ui/expression-scope-data-root.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#23036] A page component's `visibleWhen` has no `data` root, and neither of
* its two prose faces may say the shipping renderer mounts one.
*
* ## The fact being pinned
*
* objectui#9308 ruling B (2026-09-13) removed `data` as a runtime expression
* root: `SchemaRenderer` no longer publishes its injected data source as
* `data`, and the one scope channel left (`PredicateScopeProvider`) is fed in
* the shipping console only by app-shell's `ExpressionProvider`, which
* publishes `current_user`, `user`, `ctx.user`, `os.user` and `features`. So a
* `data.*` read in this predicate faults, and this surface answers a faulting
* predicate by rendering the component: `data.total > 0` and `data.total == 0`
* both show it. The `.describe()` said the renderer "binds `data` to the
* data-source ADAPTER here" — a gate written from it never gates, and nothing
* refuses it. #17203 corrected the same sentence for `app`
* (`expression-scope-app-root.pin.test.ts`); this file holds the `data` half.
*
* ## What is pinned: the claim, not the wording
*
* 1. The mounted-roots sentence names exactly `features` and `os.user`, on both
* faces (the published `.describe()` and the TSDoc an AI author reads).
* 2. On the published face, every sentence that names `data` says it is NOT
* mounted here, ties it to the PLANNED API binding, or is about the
* `page:tabs` item evaluator — the three readings that are true today.
* 3. The two texts in this file agree. The `visibleWhen` face names the
* PLANNED API binding as the only `data` carrier, which holds exactly while
* `ElementApiDataSourceSchema`'s own describe still carries
* `[PLANNED — not yet rendered]`. When that binding's renderer ships and its
* describe drops the marker, this pin goes red so the `visibleWhen` face is
* rewritten in the same change, not left behind.
*
* Rewording any of these sentences is free; a sentence that tells an author
* the renderer binds `data` on this surface is not.
*/

import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';

import { describe, it, expect } from 'vitest';

import { ElementApiDataSourceSchema, PageComponentSchema } from './page.zod';

const HERE = path.dirname(url.fileURLToPath(import.meta.url));
const pageSource = fs.readFileSync(path.resolve(HERE, 'page.zod.ts'), 'utf8');

/**
* The published description of `PageComponentSchema.visibleWhen`.
*
* ⚠️ NOT `.shape` — ADR-0089 D3a made this schema a `.strict().transform(…)`
* pipe, so the object carrying the property descriptions is its INPUT side.
*/
const visibleWhenDescription = (): string => {
const shape = (PageComponentSchema as unknown as {
def: { in: { shape: Record<string, { description?: string }> } };
}).def.in.shape;
const d = shape.visibleWhen.description;
expect(d, 'the describe() must exist — this pin is about its content').toBeTruthy();
return d!;
};

/**
* The TSDoc block directly above the `visibleWhen:` property, with the comment
* gutter stripped and its paragraphs (headings included) kept apart by a blank
* line. Anchored on its first line and on the property itself, so a block that
* moves or loses either fails here instead of asserting over nothing.
*/
const visibleWhenDocblock = (): string => {
const start = pageSource.indexOf('Conditional-visibility predicate (CEL) — the component is rendered only when');
const end = pageSource.indexOf('visibleWhen: EvaluatedExpressionInputSchema', start);
expect(start, 'docblock anchor not found — the pin has drifted off its site').toBeGreaterThan(-1);
expect(end, 'property anchor not found after the docblock').toBeGreaterThan(start);
return pageSource.slice(start, end)
.split('\n')
.map((line) => line.replace(/^\s*\*\/?\s?/, '').trimEnd())
.join('\n')
.split(/\n\s*\n/)
.map((paragraph) => paragraph.replace(/\s*\n\s*/g, ' ').trim())
.filter(Boolean)
.join('\n\n');
};

/**
* Sentences of a prose face. A paragraph break ends one; a period, `!` or `?`
* ends one only when white space and a capital, a backtick or `⚠` follow — so
* dotted tokens (`os.user`, `page.<var>`, `data.*`) and "e.g." stay inside
* their sentence.
*/
const sentences = (text: string): string[] =>
text.split(/\n\n|(?<=[.!?])\s+(?=[A-Z`⚠])/).map((s) => s.trim()).filter(Boolean);

/** The sentence a mounted-roots claim lives in, located by its anchor. */
const sentenceAt = (text: string, anchor: string): string => {
const hit = sentences(text).find((s) => s.includes(anchor));
expect(hit, `anchor not found — the pin has drifted off its site: ${anchor}`).toBeTruthy();
return hit!;
};

/** Every backticked token in a sentence: the roots it names. */
const backticked = (s: string): string[] => [...s.matchAll(/`([^`]+)`/g)].map((m) => m[1]);

/**
* The published-face rule (2): sentences naming `data` that are none of the
* three true readings. Returned rather than asserted, so the controls below can
* run it on a text that MUST fail.
*/
const dataClaimsOutsideTheTrueReadings = (text: string): string[] =>
sentences(text)
.filter((s) => s.includes('`data`'))
.filter((s) => !/\bNO `data`/.test(s) && !s.includes('PLANNED') && !s.includes('`page:tabs`'));

/** The sentence shapes the retired claim was written in, on either face. */
const RETIRED_CLAIM_SHAPES = [/binds `data` to the data-source/i, /`data` is the data-source/i] as const;

const MOUNTED_ROOTS = ['features', 'os.user'];

describe('a page component `visibleWhen` names no `data` root — the shipping renderer mounts none', () => {
describe('published face — `PageComponentSchema.visibleWhen`.describe()', () => {
it('the mounted-roots sentence names exactly `features` and `os.user`', () => {
const s = sentenceAt(visibleWhenDescription(), 'additionally mounts');
expect(backticked(s)).toEqual(MOUNTED_ROOTS);
expect(s).toContain('NOT contract-guaranteed');
});

it('states the absence, and no sentence claims `data` is bound here', () => {
const d = visibleWhenDescription();
expect(d).toContain('mounts NO `data` root');
expect(dataClaimsOutsideTheTrueReadings(d)).toEqual([]);
for (const shape of RETIRED_CLAIM_SHAPES) expect(d).not.toMatch(shape);
});

it('keeps the `page:tabs` item reading, which is still true: that evaluator binds `data` to the row', () => {
const s = sentenceAt(visibleWhenDescription(), '`page:tabs`');
expect(s).toContain('`data`');
expect(s).toContain('ROW');
});

it('keeps the contract-bound roots, a different clause this card does not touch', () => {
const s = sentenceAt(visibleWhenDescription(), 'Contract-bound roots');
for (const root of ['record', 'current_user', 'user', 'ctx.user']) expect(backticked(s)).toContain(root);
});
});

describe('TSDoc face — the docblock above `visibleWhen` (no generator reads it; an AI author opening the file does)', () => {
it('the "Ambient roots" sentence names exactly `features` and `os.user`', () => {
const s = sentenceAt(visibleWhenDocblock(), 'The shipping renderer additionally mounts');
expect(backticked(s)).toEqual([...MOUNTED_ROOTS, 'ExpressionProvider']);
});

it('states the absence, and carries neither shape the retired claim was written in', () => {
const doc = visibleWhenDocblock();
expect(doc).toContain('mounts NO `data` root');
for (const shape of RETIRED_CLAIM_SHAPES) expect(doc).not.toMatch(shape);
});
});

describe('the two texts in this file agree — the PLANNED API binding is the only `data` carrier', () => {
it('`visibleWhen` names the PLANNED API binding as where `data` is declared', () => {
const s = sentences(visibleWhenDescription()).find((x) => x.includes('PLANNED'));
expect(s, 'the describe no longer names the planned binding').toBeTruthy();
expect(s).toContain('`data`');
expect(s).toContain("provider: 'api'");
});

it('…which holds only while the API binding is still PLANNED and still declares a `data` read in `visibleWhen`', () => {
const api = ElementApiDataSourceSchema.description ?? '';
expect(
api.startsWith('[PLANNED — not yet rendered]'),
'The API binding is no longer marked PLANNED, so its renderer has shipped: on an element carrying '
+ 'that binding `data` is now mounted. Rewrite `PageComponentSchema.visibleWhen`\'s `data` sentence '
+ '(describe and docblock) in the same change, then this assertion.',
).toBe(true);
expect(api).toContain('`data`');
expect(api).toContain('`visibleWhen`');
});
});

describe('probe controls — a pass above is only a reading if these hold', () => {
/**
* DARK. The describe sentence this card replaced, verbatim. Rule (2) and
* the retired-shape list must both catch it, or their `toEqual([])` /
* `not.toMatch` arms above could be passing on a matcher that matches
* nothing.
*/
it('DARK: the replaced describe sentence fails rule (2) and the retired-shape list', () => {
const replaced = 'Contract-bound roots: `record`, `current_user`, and page state as `page.<var>`. '
+ 'The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source '
+ 'ADAPTER here — renderer behaviour, NOT contract-guaranteed (ADR-0068 rules the user object only). '
+ '⚠️ `data` is surface-dependent: on a `page:tabs` item `visibleWhen` it is the record ROW instead.';
expect(dataClaimsOutsideTheTrueReadings(replaced)).toHaveLength(1);
expect(backticked(sentenceAt(replaced, 'additionally mounts'))).toContain('data');
expect(RETIRED_CLAIM_SHAPES.some((shape) => shape.test(replaced))).toBe(true);
});

/** DARK. The sentence splitter keeps dotted tokens and "e.g." inside one sentence. */
it('DARK: dotted tokens do not split a sentence', () => {
expect(sentences('Mounts `os.user` and `page.<var>`; e.g. "page.x != \'\'". Next one.')).toHaveLength(2);
});

/** DARK. A site renamed out from under this pin fails loudly, never vacuously. */
it('DARK: a missing anchor fails the pin rather than passing over nothing', () => {
expect(() => sentenceAt(visibleWhenDescription(), 'no such anchor exists in this describe')).toThrow();
});
});
});
Loading
Loading