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
30 changes: 30 additions & 0 deletions .changeset/25712-renderer-runs-one-sdui-parser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@object-ui/components': patch
'@object-ui/console': patch
'@object-ui/layout': patch
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-gantt': patch
'@object-ui/plugin-grid': patch
'@object-ui/plugin-kanban': patch
'@object-ui/plugin-map': patch
'@object-ui/plugin-timeline': patch
'@object-ui/plugin-tree': patch
'@object-ui/react': patch
---

refactor(ui)!: the renderer runs `@objectstack/sdui-parser`, and `@object-ui/sdui-parser` is deleted (#25712)

Clause-②: yes (narrowing: a `type-mismatch` or `member-type-mismatch` the html-tier compile used to grade `warning` is now an `error`, so a page that carried one now refuses to compile instead of rendering with the value dropped)
Metadata-protocol: none

The renderer and the save gate each ran their own copy of the SDUI parser. The previous change under this card made `@objectstack/sdui-parser` the one body. This change moves every `@object-ui/*` package, the console and the examples onto it, and deletes `ui/packages/sdui-parser`. There is no forwarding release of `@object-ui/sdui-parser`: it leaves the fixed version group, and the 18.1 line on npm is its last.

- **Dependencies.** Each package above now depends on `@objectstack/sdui-parser` where it depended on `@object-ui/sdui-parser`. The console's `gen-manifest.ts` moved from the deleted package to `ui/apps/console/scripts/`.
- **Severity, the one narrowing.** The renderer's copy graded a `type-mismatch` as an `error` only beside an `enum` arm, and as a `warning` otherwise. The kept rule grades every `type-mismatch` and `member-type-mismatch` an `error`, which is what the save gate already did. So an html-tier page whose attribute does not fit the declared type now shows the compile-error block naming the prop, where it rendered the block with that value dropped. Example: `<list-view columns='["name","amount"]' />` (a string where `columns` is declared `array`). A page the save gate accepted is not affected, because the save gate already refused it.
- **Body-dialect messages.** The retired `body` child-list key's `unknown-prop` message now ends `("body" is its retired spelling)`, and its `not-a-container` message drops the `(objectui#6771)` parenthetical.

Migration: none for a page the save gate accepted. A page that relied on the warning has a value the declared type does not take: write it in the declared type, for example `columns={['name','amount']}`.

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the renderer's grading of a value that does not fit its declared type, which the save gate already refused, so no page saved through the gate changes verdict. The repair of a page written around the gate is the author's per diagnostic, which no ledger entry can derive. The other categories are closed on facts: no ADR-0087 id is named or touched (not registered or already-registered); the deleted `@object-ui/sdui-parser` stays published on npm at 18.1 and is deprecated there by the maintainer after 18.2, and no package in this change is unpublished (not unpublished); nothing exported by a package above is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
1 change: 0 additions & 1 deletion .changeset/25831-object-ui-18-1-to-merge.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
"@object-ui/app-shell": minor
"@object-ui/auth": minor
Expand Down Expand Up @@ -35,7 +35,6 @@
"@object-ui/providers": minor
"@object-ui/react": minor
"@object-ui/react-runtime": minor
"@object-ui/sdui-parser": minor
"@object-ui/types": minor
---

Expand Down
4 changes: 2 additions & 2 deletions .changeset/25926-retire-node-visibility-dialects.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
'@object-ui/types': minor
'@object-ui/react': minor
'@object-ui/sdui-parser': minor
'@objectstack/sdui-parser': patch
'@object-ui/app-shell': patch
---

Expand All @@ -16,7 +16,7 @@

- **`@object-ui/types`**: `BaseSchema.visibleOn`, `hiddenOn` and `disabledOn` are `never` on the TypeScript face and alias refusals on the Zod mirror. A document that writes one is refused at that key, and the message names the key that survives (`visibleWhen`, `hidden`, `disabled`), says the polarity is the same, and says the value is unchanged. `objectui validate` reads the same mirror, so it prints the same refusal. The `object-view` table slot's refusal of the same three keys now names the surviving node key too (write `visibleWhen` / `hidden` / `disabled` on the object-view node itself) instead of sending the author to a retired spelling. Boolean `visible` stays: the spec declares it on actions, navigation entries, tabs, alerts and settings, and the renderer keeps evaluating it.
- **`@object-ui/react`**: `SchemaRenderer`'s visibility chain has three legs, `visibleWhen` and `visible` (shown when truthy) and `hidden` (hidden when truthy), and the enablement gate has one, `disabled`. A stale retired key that reaches the renderer unvalidated is not consulted and is held out of the component's props, so it neither gates the node nor lands in the DOM.
- **`@object-ui/sdui-parser`**: the three keys are no longer base props. `validateTree` reports each as an `unknown-prop` diagnostic that names the surviving key, so a TSX tree is told the same thing a JSON document is. `checkRetiredVisibilityDialect` and `RETIRED_VISIBILITY_DIALECTS` are exported for that reading.
- **`@objectstack/sdui-parser`**: the three keys are no longer base props. `validateTree` reports each as an `unknown-prop` diagnostic that names the surviving key, so a TSX tree is told the same thing a JSON document is. `checkRetiredVisibilityDialect` and `RETIRED_VISIBILITY_DIALECTS` are exported for that reading.
- **`@object-ui/app-shell`**: the command palette reads a navigation entry's `visible` only; the `visibleOn` fallback it carried read a key `NavigationItem` never declared.

**BREAKING** (an accept-set narrowing) for any page, view or component document that writes `visibleOn`, `hiddenOn` or `disabledOn` on a node. No shipped example, fixture or skill example in this repository wrote one; the ones in the documentation and the `objectui` skill are rewritten in this change.
Expand Down
1 change: 0 additions & 1 deletion .changeset/26276-ui-package-test-vitest.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@object-ui/app-shell': patch
'@object-ui/components': patch
Expand All @@ -19,7 +19,6 @@
'@object-ui/providers': patch
'@object-ui/react': patch
'@object-ui/react-runtime': patch
'@object-ui/sdui-parser': patch
'@object-ui/types': patch
---

Expand Down
1 change: 0 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@
"@object-ui/providers",
"@object-ui/react",
"@object-ui/react-runtime",
"@object-ui/sdui-parser",
"@object-ui/types",
"object-ui"
]
Expand Down
4 changes: 2 additions & 2 deletions content/docs/protocol/backward-compatibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Read the **Breaking?** column, not the version number: in the 17.x minors every
1. **RFC (Request for Comments)** — Breaking changes are proposed as GitHub issues with the `protocol:breaking` label.
2. **Deprecation** — The old behavior is deprecated in a MINOR release (see timeline above).
3. **Migration Guide** — A detailed migration guide is published before the removal lands, in the release notes for the version that carries it.
4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 111 published packages. During the v18 prerelease it shipped in the next `18.0.0-next.N`, because the guard stood aside in pre mode and a pull request could declare a major bump. Changesets has exited pre mode, so the guard fails a `major` bump again. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line).
4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 110 published packages. During the v18 prerelease it shipped in the next `18.0.0-next.N`, because the guard stood aside in pre mode and a pull request could declare a major bump. Changesets has exited pre mode, so the guard fails a `major` bump again. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line).

---

Expand Down Expand Up @@ -196,7 +196,7 @@ The `@objectstack/spec` package provides additional stability guarantees:

### Which surfaces this covers

**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **111** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt.
**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **110** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt.

The convention was enforced rather than informal — `scripts/check-changeset-no-major.mjs` failed a pull request that introduced a `major` bump, because under lockstep a single `major` on one package would promote the entire stack. During the v18 prerelease Changesets was in pre mode with the tag `next` and the guard stood aside, so a pull request on the v18 line could declare a major bump. Changesets has exited pre mode, and the guard fails a `major` bump again.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ import { describe, it, expect } from 'vitest';
import type { ValidationResult as SpecKernelValidationResult } from '@objectstack/spec/kernel';
import type { ValidationResult as SpecContractsValidationResult } from '@objectstack/spec/contracts';

import type { ManifestValidationResult } from '../types.js';
import { validateTree } from '../validate.js';
import { validateTree, type ManifestValidationResult } from '@objectstack/sdui-parser';

describe('validateTree still returns the manifest-shaped result', () => {
it('returns diagnostics / requires / bindings, not a `valid` flag', () => {
Expand All @@ -50,22 +49,17 @@ type Extends<A, B> = [A] extends [B] ? true : false;
type IsAny<T> = 0 extends 1 & T ? true : false;
type HasKey<T, K extends string> = K extends keyof T ? true : false;

describe('the spec ValidationResults are plugin-manifest results, this one is not', () => {
it('is pinned at compile time', () => {
// Load-bearing imports: the day the spec retires either name, this file
// stops compiling and the convention can be revisited.
type _KernelIsReal = Assert<Equal<IsAny<SpecKernelValidationResult>, false>>;
type _ContractsIsReal = Assert<Equal<IsAny<SpecContractsValidationResult>, false>>;
// Load-bearing imports: the day the spec retires either name, this file stops
// compiling and the convention can be revisited. Exported so the strict
// `noUnusedLocals` of tsconfig.test.json reads them as used.
export type _KernelIsReal = Assert<Equal<IsAny<SpecKernelValidationResult>, false>>;
export type _ContractsIsReal = Assert<Equal<IsAny<SpecContractsValidationResult>, false>>;

type _SpecCarriesAValidFlag = Assert<HasKey<SpecKernelValidationResult, 'valid'>>;
type _ThisOneDoesNot = Assert<Equal<HasKey<ManifestValidationResult, 'valid'>, false>>;
type _ThisOneCarriesRequires = Assert<HasKey<ManifestValidationResult, 'requires'>>;
export type _SpecCarriesAValidFlag = Assert<HasKey<SpecKernelValidationResult, 'valid'>>;
export type _ThisOneDoesNot = Assert<Equal<HasKey<ManifestValidationResult, 'valid'>, false>>;
export type _ThisOneCarriesRequires = Assert<HasKey<ManifestValidationResult, 'requires'>>;

// Not assignable in either direction — sharing the name was never a
// narrowing or a widening, it was two subjects under one word.
type _NotAssignable = Assert<Equal<Extends<ManifestValidationResult, SpecKernelValidationResult>, false>>;
type _NorTheOtherWay = Assert<Equal<Extends<SpecKernelValidationResult, ManifestValidationResult>, false>>;

expect(true).toBe(true);
});
});
// Not assignable in either direction — sharing the name was never a narrowing
// or a widening, it was two subjects under one word.
export type _NotAssignable = Assert<Equal<Extends<ManifestValidationResult, SpecKernelValidationResult>, false>>;
export type _NorTheOtherWay = Assert<Equal<Extends<SpecKernelValidationResult, ManifestValidationResult>, false>>;
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
* nothing, while a near-miss spelling still draws `unknown-prop` (the lit
* control — without it, "draws nothing" would also pass against a tier
* that stopped warning on undeclared keys);
* - MEMBER 1: `visibleWhen`, `hiddenOn` and `testId` are accepted on every
* node, as `bind` and `hidden` are;
* - MEMBER 1: `visibleWhen` and `testId` are accepted on every node, as
* `bind` and `hidden` are; the retired `visibleOn` / `hiddenOn` /
* `disabledOn` dialects are answered by name (`retired-visibility-dialects.ts`);
* - MEMBER 2 (the triage ruling): a `'where-undeclared'` member is a base
* prop only where the type declares no input of that name. Where one does,
* the declared input wins, `type-mismatch` included;
Expand Down Expand Up @@ -100,11 +101,8 @@ const WELL_TYPED: Record<string, unknown> = {
style: { padding: 4 },
visible: true,
visibleWhen: "data.role == 'admin'",
visibleOn: "data.role == 'admin'",
hidden: true,
hiddenOn: "data.role != 'admin'",
disabled: false,
disabledOn: 'data.locked',
bind: 'users',
testId: 'users-list',
children: [],
Expand Down Expand Up @@ -155,11 +153,27 @@ describe('objectui#11044 — the base props are one list, read by the validator
});
});

describe('objectui#11044 member 1 — `visibleWhen`, `hiddenOn` and `testId` are base props of every node', () => {
it.each(['visibleWhen', 'hiddenOn', 'testId'])('`%s` is an `every-node` member', (key) => {
describe('objectui#11044 member 1 — `visibleWhen` and `testId` are base props of every node', () => {
it.each(['visibleWhen', 'testId'])('`%s` is an `every-node` member', (key) => {
expect(SDUI_BASE_PROPS.find((p) => p.name === key)?.scope).toBe('every-node');
expect(diagnose({ ...LEAF, [key]: WELL_TYPED[key] })).toEqual([]);
});

// The retired node-level dialects are NOT base props: each draws ONE
// `unknown-prop` that names the canonical key, the way `body` names
// `children` — a replacement for the bare "has no prop", never a second line.
it.each([
['visibleOn', 'visibleWhen'],
['hiddenOn', 'hidden'],
['disabledOn', 'disabled'],
] as const)('the retired `%s` draws one `unknown-prop` naming `%s`', (key, canonical) => {
expect(SDUI_BASE_PROPS.find((p) => p.name === key)).toBeUndefined();
const found = diagnose({ ...LEAF, [key]: 'data.locked' });
expect(found.map((d) => d.code)).toEqual(['unknown-prop']);
expect(found[0]!.message).toContain(`"${key}"`);
expect(found[0]!.message).toContain(`"${canonical}"`);
expect(found[0]!.message).toContain('retired spelling');
});
});

describe('objectui#11044 member 2 — a declared input outranks a `where-undeclared` base prop', () => {
Expand All @@ -185,7 +199,7 @@ const TSX_CASES: ReadonlyArray<{ what: string; code: string; rejected: boolean }
{ what: '`bind` (objectui#11008)', rejected: false, code: `export const c1 = <list bind="users" />;` },
{ what: '`hidden` (objectui#11008)', rejected: false, code: `export const c2 = <list hidden={true} />;` },
{ what: '`visibleWhen`', rejected: false, code: `export const c3 = <list visibleWhen="data.role == 'admin'" />;` },
{ what: '`hiddenOn`', rejected: false, code: `export const c4 = <list hiddenOn="data.archived" />;` },
{ what: 'the retired `hiddenOn` is no JSX attribute either', rejected: true, code: `export const c4 = <list hiddenOn="data.archived" />;` },
{ what: '`testId`', rejected: false, code: `export const c5 = <list testId="users-list" />;` },
{ what: 'an undeclared `label`', rejected: false, code: `export const c6 = <list label="Users" />;` },
{ what: 'lit control: a near-miss', rejected: true, code: `export const c7 = <list bindTo="users" />;` },
Expand Down
19 changes: 16 additions & 3 deletions packages/sdui-parser/src/__tests__/base-props-one-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,16 @@ const codes = (node: SchemaElement): string[] =>
validateTree(node, manifest).diagnostics.map((d) => d.code);

const EVERY_NODE = [
'id', 'className', 'style', 'visible', 'visibleWhen', 'visibleOn', 'hidden',
'hiddenOn', 'disabled', 'disabledOn', 'bind', 'testId', 'children',
'id', 'className', 'style', 'visible', 'visibleWhen', 'hidden',
'disabled', 'bind', 'testId', 'children',
];
// The retired node-level dialects (#25926) left the list: each draws ONE
// `unknown-prop` that names its canonical key (`retired-visibility-dialects.ts`).
const RETIRED_DIALECTS = [
['visibleOn', 'visibleWhen'],
['hiddenOn', 'hidden'],
['disabledOn', 'disabled'],
] as const;
// `type` is the one `every-node` entry with no attribute form: on a node it
// IS the tag, so a node cannot carry it without being the component under test.
// It is pinned through the list below, not through `validateTree`.
Expand All @@ -41,6 +48,12 @@ describe('base props — one list, two scopes (objectui#11008, #11044)', () => {
expect(codes(node)).not.toContain('unknown-prop');
});

it.each(RETIRED_DIALECTS)('the retired `%s` draws one unknown-prop naming `%s`', (key, canonical) => {
const diags = validateTree({ type: 'leaf', [key]: 'x' } as SchemaElement, manifest).diagnostics;
expect(diags.map((d) => d.code)).toEqual(['unknown-prop']);
expect(diags[0]!.message).toContain(`"${canonical}"`);
});

it.each(WHERE_UNDECLARED)('undeclared `%s` draws no unknown-prop', (key) => {
expect(codes({ type: 'leaf', [key]: 'x' } as SchemaElement)).toEqual([]);
});
Expand All @@ -51,7 +64,7 @@ describe('base props — one list, two scopes (objectui#11008, #11044)', () => {
expect(validateTree({ type: 'labelled', label: 'ok' } as SchemaElement, manifest).diagnostics).toEqual([]);
});

it('the list is the 20 entries, `type` alone having no attribute type', () => {
it('the list is the 17 entries, `type` alone having no attribute type', () => {
expect(SDUI_BASE_PROPS.map((p) => p.name)).toEqual(['type', ...EVERY_NODE, ...WHERE_UNDECLARED]);
expect(SDUI_BASE_PROPS.filter((p) => p.tsType === null).map((p) => p.name)).toEqual(['type']);
expect(SDUI_BASE_PROPS.filter((p) => p.scope === 'every-node').map((p) => p.name)).toEqual(['type', ...EVERY_NODE]);
Expand Down
4 changes: 2 additions & 2 deletions packages/sdui-parser/src/dashboard-widget-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
* repo-wide scan would red on a TRUE claim — and an assertion that reds on
* legitimate code gets deleted by the next person who hits it, which puts
* the claim back where it started. Leg 2 of
* `ui/packages/sdui-parser/src/__tests__/dashboard-widget-options-census.test.ts`
* `ui/packages/plugin-dashboard/src/__tests__/dashboard-widget-options-census.test.ts`
* derives exactly this bound: the DatasetWidget read set is the declared
* set, and neither `format` nor `thresholds` is in it. That absence is
* asserted in its OWN right rather than riding on an equality whose
Expand Down Expand Up @@ -101,7 +101,7 @@
*
* ## Maintenance — how this list stays true
*
* `ui/packages/sdui-parser/src/__tests__/dashboard-widget-options-census.test.ts`
* `ui/packages/plugin-dashboard/src/__tests__/dashboard-widget-options-census.test.ts`
* re-runs the census on every test run: it re-derives the five declared keys from the installed
* `@objectstack/spec`, re-extracts the `options.<key>` reads from
* `DatasetWidget.tsx` source text (and fails loudly if that file gains a
Expand Down
1 change: 1 addition & 0 deletions packages/sdui-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export { generateDts, propsName, generateBlockList } from './codegen.js';
export type { CodegenOptions } from './codegen.js';
export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js';
export { checkRetiredBodyDialect, RETIRED_CHILD_LIST_KEY } from './body-dialect.js';
export { checkRetiredVisibilityDialect, RETIRED_VISIBILITY_DIALECTS } from './retired-visibility-dialects.js';

import { parseJsx } from './parse.js';
import { validateTree } from './validate.js';
Expand Down
Loading
Loading