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
18 changes: 18 additions & 0 deletions .changeset/11564-flex-bag-children-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/types': minor
---

`FlexBlockNode`, the TypeScript type of an authored `flex` node, types its bag's child list as nodes: `properties.children` is `SchemaNode | SchemaNode[]`, where it was `unknown[] | SchemaNode` (objectui#11564).

**Clause-②: yes (narrowing)**, TypeScript authoring face only, shipped as `minor` per this repository's version policy. The list is the form a `flex` node is authored in (`{ type: 'flex', properties: { children: [ … ] } }`), and until now each entry was `unknown`, while a single child was already judged as a node. Each list entry is now judged as a node too, against its own `type`:

- an entry whose `type` no declaration names, or an object with no `type`, no longer compiles;
- an entry that is itself a list no longer compiles;
- a misspelled key on a node type without an index signature (the spec-derived nodes, such as `element:text`, and a closed `CustomNodeRegistry` entry) no longer compiles. Node types that extend `BaseSchema` keep its index signature until objectui#8347 removes it, so a misspelled key on one of them still compiles, in the list exactly as in a single child;
- a primitive entry (a string, a number, `null`) still compiles, as in every node slot.

The type is the flat `FlexSchema` mirror's own `children` member, by reference, which is also the member `FlexLayoutProps` declares. Every other member of `FlexBlockNode` and of its bag is still read off the `FlexBlockSchema` arm.

What does NOT move: every zod face and every runtime path. `FlexBlockSchema` keeps `z.array(z.unknown())` for the list, because `@objectstack/spec`'s page walk already judges each entry there, once, at its real path, on the tolerant and the strict face (objectui#11223). The one entry kind where the faces now differ is a nested list, which the walk passes through unvisited and the TypeScript face refuses; `@object-ui/types`' mirror-parity ledger records the divergence.

**Migration.** Give each list entry its declared node type (or `DeclaredNode`), declare a custom type in `CustomNodeRegistry`, and flatten a nested list into the one list.
8 changes: 5 additions & 3 deletions packages/types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,9 +444,11 @@ const main: ContainerSchema = {
};

// An authored `flex` node takes its props, the child list included, in its
// `properties` bag. `FlexBlockNode` is that node, its bag closed. The bag's
// child list is `unknown[]`, as `FlexBlockSchema` declares it, so each child
// written inline names its own type with `satisfies`.
// `properties` bag. `FlexBlockNode` is that node, its bag closed, and each
// entry of the bag's child list is checked as a node of its own `type`. The
// nested `flex` still names its type with `satisfies`: as an entry it also
// matches the post-hoist `FlexSchema`, whose `BaseSchema` index signature
// admits any `properties` until that signature is removed.
const page: FlexBlockNode = {
type: 'flex',
properties: {
Expand Down
8 changes: 5 additions & 3 deletions packages/types/examples/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import type { FlexBlockNode, SidebarSchema, HeaderBarSchema, CardSchema, DataTab

// An authored `flex` node takes its props, the child list included, in its
// `properties` bag (objectui#11276). `FlexBlockNode` is that node, its bag
// closed (objectui#11468). The bag's child list is `unknown[]`, as the zod arm
// declares it (the page walk judges each entry), so the nested `flex` node is
// checked with `satisfies FlexBlockNode`.
// closed (objectui#11468), and each entry of the bag's child list is typed as
// a node (objectui#11564). A `flex` entry also matches the post-hoist
// `FlexSchema`, whose `BaseSchema` index signature admits any `properties`
// until objectui#8347 removes it, so the nested `flex` node is checked with
// `satisfies FlexBlockNode`.
export const dashboardSchema: FlexBlockNode = {
type: 'flex',
className: 'h-screen',
Expand Down
164 changes: 164 additions & 0 deletions packages/types/src/__tests__/flex-bag-children-list-11564.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#11564: the `flex` bag's child LIST is typed as nodes on the
* TypeScript face, as a single child already was, pinned.
*
* ## The gap
*
* `FlexBlockNode` was read off the `FlexBlockSchema` arm, whose bag member
* `children` is `FLEX_BAG_CHILDREN`: a single node (`SchemaNodeSchema`) or
* `z.array(z.unknown())`. The zod faces need the `unknown`: a list at
* `properties.children` is a position `@objectstack/spec`'s page walk
* descends, and each entry is judged there, once, at its real path
* (`flex-properties-bag-11276.test.ts` pins that on both faces). On the
* TypeScript face the same member read `unknown[]`, so the list, the form a
* `flex` node is authored in, took any entry while a single child was judged
* as a node. With `BaseSchema`'s index signature present nothing showed, since
* the signature absorbs a misspelled key either way; once objectui#8347
* removes it, the list would have been the one unchecked node slot.
*
* ## What is pinned here
*
* TYPE-level rows, judged by this package's `type-check` (its
* `tsc -p tsconfig.test.json` leg), never by vitest:
*
* - the bag's `children` is one node or a list of nodes, the flat mirror's
* own member, and it is the only member that differs from the arm;
* - a closed node's misspelling inside the list is refused today. A
* spec-derived node carries no index signature, so it is the shape every
* node has after objectui#8347; the control is the same document typed
* the way the arm reads it, which compiles;
* - an undeclared `type` and a nested array inside the list are refused,
* and a primitive entry compiles, as in every node slot.
*
* A runtime leg reads the two zod faces on the same entries, so the one kind
* where the faces differ (a nested array) is measured here rather than stated.
* That divergence is recorded in `zod-mirror-parity.test.ts`'s `EXCLUSIONS`
* row for `layout.zod.ts#FlexBlockSchema`.
*/

import { describe, expect, it } from 'vitest';
import type { z } from 'zod';
import type { FlexBlockNode } from '../authoring-nodes.js';
import type { SchemaNode } from '../base.js';
import type { FlexLayoutProps } from '../layout.js';
import {
FlexBlockSchema,
FlexSchema,
StrictAnyComponentSchema,
safeValidateSchema,
} from '../zod/index.zod.js';

type Assert<T extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;

/** The `flex` bag as `FlexBlockNode` types it. */
type Bag = NonNullable<FlexBlockNode['properties']>;

/** A shape with its `properties` member read closed: the derivation `FlexBlockNode` had before objectui#11564. */
type ClosedArmShape<S> = {
[K in keyof S]: K extends 'properties'
? S[K] extends z.ZodOptional<infer Inner extends z.ZodObject>
? z.ZodOptional<z.ZodObject<Inner['shape']>>
: S[K]
: S[K];
};

/** `FlexBlockSchema` read off its arm alone, its bag closed: what `FlexBlockNode` was. */
type ArmInput = z.input<z.ZodObject<ClosedArmShape<(typeof FlexBlockSchema)['shape']>>>;
type ArmBag = NonNullable<ArmInput['properties']>;

// 1. The bag's `children` is one node or a list of nodes: the flat mirror's own member, the slot
// `FlexLayoutProps` declares, and the single-or-list slot every node slot takes (objectui#11466).
type _BagChildrenIsANodeOrAList = Assert<Equal<Bag['children'], SchemaNode | SchemaNode[] | undefined>>;
type _BagChildrenIsTheMirrorsMember = Assert<Equal<Bag['children'], z.input<(typeof FlexSchema)['shape']['children']>>>;
type _BagChildrenIsTheDeclaredMember = Assert<Equal<Bag['children'], FlexLayoutProps['children']>>;

// 2. The arm did not move (control): its list is still `unknown[]`, left to the page walk.
type _ControlTheArmsListIsUnknown = Assert<Equal<ArmBag['children'], unknown[] | SchemaNode | undefined>>;

// 3. That is the only member that differs, on the node and in the bag.
type _NodeMembersAreTheArms = Assert<Equal<Omit<FlexBlockNode, 'properties'>, Omit<ArmInput, 'properties'>>>;
type _BagMembersAreTheArms = Assert<Equal<Omit<Bag, 'children'>, Omit<ArmBag, 'children'>>>;
type _BagStaysOptional = Assert<Equal<undefined extends FlexBlockNode['properties'] ? true : false, true>>;

type Issue = { code: string; path: string };
type Result = { success: boolean; error?: { issues: z.core.$ZodIssue[] } };

const FACES: ReadonlyArray<readonly [string, (document: unknown) => Result]> = [
['tolerant', (document) => safeValidateSchema(document)],
['strict', (document) => StrictAnyComponentSchema.safeParse(document)],
];

const issuesOf = (result: Result): Issue[] =>
(result.error?.issues ?? []).map((issue) => ({ code: issue.code, path: issue.path.join('.') }));

describe('objectui#11564: the flex bag\'s child list is typed as nodes', () => {
it('a closed node\'s misspelling inside the list is refused; the arm\'s own reading let it through', () => {
// The card's probe f02, with a closed node: `contnt` is not a member of `element:text`'s bag.
// @ts-expect-error the list entry is judged as a node, and the misspelling is refused
const inList: FlexBlockNode = { type: 'flex', properties: { children: [{ type: 'element:text', properties: { contnt: 'x' } }] } };
// Control: typed the way the arm reads it (`unknown[]`), the same document compiles.
const armReading: ArmInput = { type: 'flex', properties: { children: [{ type: 'element:text', properties: { contnt: 'x' } }] } };
// Control: the same misspelling as a single child was refused before this card too.
// @ts-expect-error `contnt` is not a member of `element:text`'s bag
const single: FlexBlockNode = { type: 'flex', properties: { children: { type: 'element:text', properties: { contnt: 'x' } } } };
expect([inList, armReading, single]).toHaveLength(3);
});

it('a well-formed list compiles: declared nodes, primitives, a nested flex', () => {
const list: FlexBlockNode = {
type: 'flex',
properties: {
direction: 'col',
gap: 4,
children: [
{ type: 'text', content: 'Orders' },
{ type: 'element:text', properties: { content: 'Total' } },
{ type: 'flex', properties: { children: [{ type: 'button', label: 'New order' }] } },
'plain text',
3,
null,
],
},
};
expect(list.properties?.children).toHaveLength(6);
});

it('an undeclared `type` and a nested array inside the list are refused', () => {
// @ts-expect-error `no-such-type-11564` names no declared node type
const undeclared: FlexBlockNode = { type: 'flex', properties: { children: [{ type: 'no-such-type-11564' }] } };
// @ts-expect-error an entry that is itself a list is not a node
const nested: FlexBlockNode = { type: 'flex', properties: { children: [[{ type: 'text', content: 'x' }]] } };
// @ts-expect-error an object with no `type` is not a node
const untyped: FlexBlockNode = { type: 'flex', properties: { children: [{ content: 'x' }] } };
expect([undeclared, nested, untyped]).toHaveLength(3);
});

it('the zod faces, on the same entries: they agree, except on a nested array', () => {
const doc = (children: unknown[]) => ({ type: 'flex', properties: { children } });
for (const [, judge] of FACES) {
// Agree: a closed node's misspelling and an undeclared `type` are refused at the entry's own path.
expect(issuesOf(judge(doc([{ type: 'element:text', properties: { contnt: 'x' } }]))).map((issue) => issue.path)).toEqual([
'properties.children.0.properties.content',
'properties.children.0.properties',
]);
expect(issuesOf(judge(doc([{ type: 'no-such-type-11564' }])))).toEqual([
{ code: 'invalid_union', path: 'properties.children.0.type' },
]);
// Agree: primitives are admitted, as in every node slot.
expect(judge(doc(['plain text', 3, null])).success).toBe(true);
// Differ: the page walk passes a nested array through unvisited, so both zod faces admit it,
// while the TypeScript face refuses it (above), as the flat mirror's own member does.
expect(judge(doc([[{ type: 'text', content: 'x' }]])).success).toBe(true);
expect(FlexSchema.safeParse({ type: 'flex', children: [[{ type: 'text', content: 'x' }]] }).success).toBe(false);
}
});
});
7 changes: 5 additions & 2 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4913,9 +4913,12 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
// objectui#11300) — the authored `flex` arm, built as the `object-chart` arm
// with NO spec row: its bag is the flat mirror's own members. Its TS twin is
// paired with that FLAT mirror (`FlexSchema`), which stays the renderer's
// post-hoist reading.
// post-hoist reading. objectui#11564 — the arm's TypeScript authoring face,
// `FlexBlockNode` (`../authoring-nodes.ts`), DIVERGES from it on one member:
// the bag's `children` list. The reason's last clause records it, and
// `./flex-bag-children-list-11564.test.ts` re-derives it on both sides.
'layout.zod.ts#FlexBlockSchema':
"mirror-owned BY REFERENCE — `BaseSchema` + the `flex` literal + `NODE_ENVELOPE` + `properties`, whose `direction` / `justify` / `align` / `gap` / `wrap` ARE the flat `layout.zod.ts#FlexSchema` mirror's own members (the same schema objects; `@objectstack/spec` has no `ComponentPropsMap['flex']` row) and whose `children` is the mirror's single-node-or-list slot with the list's entries left to the spec page walk's judgment, + `flatPropRefusals` over the bag (one `aliasKeyRefusal` per bag member, pointed at `properties.KEY`; the key set is read off the bag), + a `body` refusal pointed at `properties.children`; the TS declaration of the node, `FlexSchema` (`../layout.ts`), is the renderer's post-hoist reading and is paired with the flat mirror, not with this arm",
"mirror-owned BY REFERENCE — `BaseSchema` + the `flex` literal + `NODE_ENVELOPE` + `properties`, whose `direction` / `justify` / `align` / `gap` / `wrap` ARE the flat `layout.zod.ts#FlexSchema` mirror's own members (the same schema objects; `@objectstack/spec` has no `ComponentPropsMap['flex']` row) and whose `children` is the mirror's single-node-or-list slot with the list's entries left to the spec page walk's judgment, + `flatPropRefusals` over the bag (one `aliasKeyRefusal` per bag member, pointed at `properties.KEY`; the key set is read off the bag), + a `body` refusal pointed at `properties.children`; the TS declaration of the node, `FlexSchema` (`../layout.ts`), is the renderer's post-hoist reading and is paired with the flat mirror, not with this arm; TS/zod DIVERGENCE (objectui#11564): the arm's authoring face, `FlexBlockNode` (`../authoring-nodes.ts`), is read off this arm's shape except for the bag's `children`, which it reads off the flat mirror's own `children` member (one node or a list of nodes), because this arm's list is `z.array(z.unknown())` and only the page walk judges its entries — so the TS face judges each object entry as a node of its own `type`, as the walk does, admits a primitive entry, as the walk does, and refuses a nested-array entry, which the walk passes through unvisited",
'navigation.zod.ts#NavLinkSchema':
"recursive; a `z.lazy` exposes no `.shape` to read, so there is no key set for the per-key comparison. Since objectui#7760 it carries its TS declaration as BOTH type arguments, so the pair IS compared — as a whole type, by `tsc`, at the annotation itself",
'navigation.zod.ts#NavigationMenuItemSchema':
Expand Down
Loading
Loading