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
9 changes: 9 additions & 0 deletions .changeset/11440-arm-passing-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,12 @@
The strict authoring face (`StrictAnyComponentSchema`) derives from these arms and accepts the same documents, closed to undeclared keys.

**The TypeScript face.** `@object-ui/types` exports `ObjectPivotBlockNode` and `EmbeddableFormBlockNode`, the two arms' authoring inputs, derived by reference as the other `ObjectQLPublicBlockNode` members are, so they are in `AuthoringNode` and `SchemaRenderer`'s `schema` prop accepts them. Each bag is required and closed: a misspelled bag key, a missing bag and a prop written flat do not compile. The page kinds were already typed (`PageDocumentNode`, the spec's own page input).

⚠️ **Dated note, 2026-10-02 — `schema` is now declared — objectui#11494.**
Later in this same release the registration's third input, `schema`, is
declared on `AppSchemaRendererNodeSchema` as the app document, by reference
(`AppComponentSchema` itself, optional), and `@object-ui/layout`'s
registration adapter delivers it: an `app-schema-renderer` node draws the
document nested under `schema`, and the strict face accepts it there. The
sentence above that says `schema` is not declared, and that the strict face
refuses it as an unrecognized key, no longer describes the node. The rest of this entry is kept as the reading of this change.
13 changes: 13 additions & 0 deletions .changeset/11494-layout-app-schema-renderer-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/layout': minor
---

An `app-schema-renderer` node draws the app document it carries under `schema` (objectui#11494, triage ruling A).

**Breaking for a node that writes the app document's keys flat on itself** (shipped as `minor`, per this repository's version policy). `SchemaRenderer` hands every registered component the node itself as its `schema` prop, and `registerLayout()` registered `AppSchemaRenderer` directly, so the component took the node for its app document: a document nested under the node's `schema` key drew an empty shell, and only app keys written flat on the node (`navigation`, `title`, …), which no schema declares, drew. `registerLayout()` now registers the key against an adapter that hands `node.schema` to `AppSchemaRenderer`:

- `{ "type": "app-schema-renderer", "schema": { "type": "app", "name": "crm", "navigation": [ … ] } }` draws the document's branding and navigation.
- The same keys written flat on the node are no longer read. Move them under `schema`, with `"type": "app"`. No producer in objectui or objectstack authors them flat.
- A node without `schema` draws the shell with no branding and no navigation, as it did before.

Unchanged: `basePath` and `mobileNavMode` are read off the node, the registration's three declared `inputs`, and `AppSchemaRenderer` itself, whose JSX hosts pass `schema={appDocument}` directly. A node's `children` are still not rendered.
16 changes: 16 additions & 0 deletions .changeset/11494-types-app-schema-renderer-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/types': minor
---

`AppSchemaRendererNodeSchema` declares the `app-schema-renderer` node's `schema` input: it is the app document, `AppComponentSchema` itself, by reference, and optional (objectui#11494, triage ruling A). objectui#11440 left `schema` undeclared because no node delivered it; `@object-ui/layout` now delivers it, so that sentence of objectui#11440's entry no longer holds.

**Clause-②: yes** — the node's accept set changes on both faces:

- **Strict face (`StrictAnyComponentSchema`): widens.** A node carrying its app document under `schema` validates; it was refused there with `unrecognized_keys` naming `schema`. The nested document is closed like every other object on this face, so an unknown key inside it is refused by name.
- **Tolerant face (`safeValidateSchema`, which `objectui validate` runs): narrows for a malformed document.** `schema` used to pass unjudged, as every undeclared key does; it is now judged as the app document. A nested value that is not one is refused at its own path: a document without `"type": "app"` at `schema.type` (`invalid_value`), a non-object at `schema` (`invalid_type`), and `mobileNavMode` inside the document at `schema.mobileNavMode`, by the app document's own objectui#11363 refusal. No producer in objectui or objectstack authors an `app-schema-renderer` node with a nested document, so nothing in either repository is refused.

What stays the same: `basePath` and `mobileNavMode` on the node, as declared since objectui#11440 (a node without `schema`, such as the one the mobile guide teaches, still validates on both faces); the `children` / `body` refusals. The document's keys written flat on the node (`navigation`, `title`, `areas`, …) stay undeclared, so the strict face refuses them as unrecognized keys: the document has one spelling, nested.

```json
{ "type": "app-schema-renderer", "basePath": "/apps/crm", "schema": { "type": "app", "name": "crm", "navigation": [] } }
```
40 changes: 36 additions & 4 deletions content/docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,42 @@ Two doors remain, one per capability:

- **Compose in React** — `<AppShell>` as shown above, with your JSON pages rendered
*inside* it through `SchemaRenderer`.
- **The whole shell from metadata** — `AppSchemaRenderer`, registered as
`app-schema-renderer` and declaring its `inputs`, which builds branding and sidebar
navigation from an `AppSchema` JSON document and takes the page content as its
`children`.
- **The whole shell from metadata** — the `app-schema-renderer` node, which renders
`AppSchemaRenderer`. It builds the branding and the sidebar navigation from the app
document it carries under `schema`, below. It draws no page content: the node's
`children` are not rendered, and `objectui validate` refuses them on this node. Page
content reaches the shell only as React `children`, when a host renders
`AppSchemaRenderer` in JSX.

The node takes three inputs:

- `schema`: the app document, nested, with its own `"type": "app"`. It is validated as
the app document, so the document's own refusals apply inside it: `mobileNavMode`
there is refused, because the mode belongs on the node.
- `basePath`: the prefix of every href the shell generates.
- `mobileNavMode`: `"drawer"` (the default) or `"bottom_nav"`, which adds a fixed
bottom bar.

```json
{
"type": "app-schema-renderer",
"basePath": "/apps/crm",
"mobileNavMode": "bottom_nav",
"schema": {
"type": "app",
"name": "crm",
"title": "CRM",
"navigation": [
{ "id": "home", "type": "page", "pageName": "home", "label": "Home" }
]
}
}
```

The document has one spelling. Its keys written flat on the node (`"navigation"`,
`"title"` beside `"type": "app-schema-renderer"`) are not drawn, and the strict authoring
face (`StrictAnyComponentSchema` in `@object-ui/types/zod`) refuses them as unrecognized
keys. A node without `schema` draws the shell with no branding and no navigation.

### Features

Expand Down
9 changes: 7 additions & 2 deletions examples/schema-catalog/test/pageheader-with-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,13 @@ describe('the retired `page-header` alias is unknown to the manifest (objectui#1
});
});

/** The layout key the same manifest still resolves — the lit control below. */
const APP_SCHEMA_NODE = { type: 'app-schema-renderer', schema: { name: 'crm', navigation: [] } };
/**
* The layout key the same manifest still resolves — the lit control below. Its
* app document is nested under `schema` with its own `type: 'app'`, the one
* spelling `AppSchemaRendererNodeSchema` accepts since objectui#11494, so the
* node is clean on every face, not only in this manifest.
*/
const APP_SCHEMA_NODE = { type: 'app-schema-renderer', schema: { type: 'app', name: 'crm', navigation: [] } };

describe('the retired `navigation-renderer` and `responsive-grid` keys are unknown to the manifest (objectui#11441)', () => {
it('draws `unknown-component` for both spellings of `navigation-renderer`', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@
/**
* objectui#11440 — the `app-schema-renderer` registration and its zod arm.
*
* `@object-ui/types/zod`'s `AppSchemaRendererNodeSchema` declares two of the
* registration's three inputs, each as the registration types it:
* `mobileNavMode` (the same two-value enum) and `basePath` (a string). The
* third input, `schema`, is NOT declared, because no node delivers it:
* `SchemaRenderer` strips the `schema` key and hands the component the NODE as
* its `schema` prop. The last row measures that through the real
* `SchemaRenderer` and registry, so the arm's omission is a reading, not an
* assumption: navigation nested under `schema` draws nothing, and the same
* navigation on the node draws.
* `@object-ui/types/zod`'s `AppSchemaRendererNodeSchema` declares
* `mobileNavMode` (the same two-value enum) and `basePath` (a string), each as
* the registration types it.
*
* The third input, `schema`, moved out of this file ON PURPOSE (objectui#11494,
* triage ruling A `5958227972`). This file measured that no node delivered it:
* `SchemaRenderer` hands a registered component the NODE as its `schema` prop,
* so navigation nested under `schema` drew nothing, and the same navigation on
* the node drew. The registration now goes through an adapter that hands
* `node.schema` to `AppSchemaRenderer`, and the arm declares `schema` as the
* app document by reference, so that row inverted: the nested document draws
* and the flat one does not. Its rows are
* `app-schema-renderer-schema-input-11494.test.tsx`.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '@object-ui/react';
import { AppSchemaRendererNodeSchema } from '@object-ui/types/zod';
import { registerLayout } from '../index';

Expand All @@ -51,17 +51,4 @@ describe('app-schema-renderer — registration inputs and the arm (objectui#1144
expect(AppSchemaRendererNodeSchema.shape.basePath.safeParse('/apps/crm').success).toBe(true);
expect(AppSchemaRendererNodeSchema.shape.basePath.safeParse(7).success).toBe(false);
});

it('`schema` is an input the arm does not declare — and a node never delivers it to the component', () => {
expect(inputs().map((input) => input.name)).toContain('schema');
expect(Object.keys(AppSchemaRendererNodeSchema.shape)).not.toContain('schema');

const nested = { type: 'app-schema-renderer', schema: { name: 'crm', navigation: [{ id: 'n1', type: 'url', label: 'NestedNav11440', url: '/x' }] } };
const onNode = { type: 'app-schema-renderer', name: 'crm', navigation: [{ id: 'n2', type: 'url', label: 'NodeNav11440', url: '/y' }] };
const drawn = (node: unknown) =>
render(<MemoryRouter><SchemaRenderer schema={node as never} /></MemoryRouter>).container.innerHTML;
expect(drawn(nested)).not.toContain('NestedNav11440');
// Lit control: the same navigation written on the node is drawn.
expect(drawn(onNode)).toContain('NodeNav11440');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 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#11494 — an `app-schema-renderer` node draws the app document it
* carries under `schema` (triage ruling A, comment `5958227972`).
*
* `SchemaRenderer` strips a node's `schema` key out of the props it spreads and
* hands every registered component the NODE as its `schema` prop. Registered
* directly, `AppSchemaRenderer` therefore read the app document's keys off the
* node itself, and the published `schema` input never arrived: objectui#11440
* measured that a node with its navigation under `schema` drew none of it,
* while the same navigation written on the node drew. That row is inverted
* here, on purpose, under the ruling: the registration now goes through an
* adapter that hands `node.schema` to `AppSchemaRenderer`, so the nested
* document is the one spelling that draws, and the flat one draws nothing.
* `@object-ui/types/zod`'s `AppSchemaRendererNodeSchema` declares `schema` as
* the app document by reference, and the strict face refuses the flat
* spelling; those verdicts are pinned in `@object-ui/types`
* (`app-schema-renderer-schema-input-11494.test.ts`).
*
* Every row renders through the real `SchemaRenderer` and the real registry.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import React from 'react';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '@object-ui/react';
import { AppSchemaRendererNodeSchema } from '@object-ui/types/zod';
import { registerLayout } from '../index';

beforeAll(() => {
registerLayout();
});

const NAV = [{ id: 'accounts', type: 'object', objectName: 'account', label: 'Accounts11494' }];

const draw = (node: unknown) =>
render(
<MemoryRouter>
<SchemaRenderer schema={node as never} />
</MemoryRouter>,
).container;

/** `SchemaErrorBoundary`'s banner, which a throwing render leaves behind. */
const FAILED_TO_RENDER = 'failed to render';

describe('app-schema-renderer — the `schema` input carries the app document (objectui#11494)', () => {
it('a node with its navigation under `schema` draws it', () => {
const container = draw({ type: 'app-schema-renderer', schema: { type: 'app', name: 'crm', navigation: NAV } });
expect(container.textContent).toContain('Accounts11494');
expect(container.textContent).not.toContain(FAILED_TO_RENDER);
});

it('the same navigation written flat on the node draws nothing: one spelling', () => {
const container = draw({ type: 'app-schema-renderer', name: 'crm', navigation: NAV });
expect(container.textContent).not.toContain('Accounts11494');
// The shell itself still draws, so the absence is the navigation's, not the node's.
expect(container.querySelector('[data-sidebar="sidebar"]')).not.toBeNull();
expect(container.textContent).not.toContain(FAILED_TO_RENDER);
});

it('the document draws its branding too: `title` under `schema` heads the sidebar', () => {
const container = draw({ type: 'app-schema-renderer', schema: { type: 'app', name: 'crm', title: 'Title11494' } });
expect(container.textContent).toContain('Title11494');
// The flat spelling does not.
expect(draw({ type: 'app-schema-renderer', title: 'Flat11494' }).textContent).not.toContain('Flat11494');
});

it('`basePath` and `mobileNavMode` are unchanged: still read off the node, beside the document', () => {
const container = draw({
type: 'app-schema-renderer',
basePath: '/apps/crm',
mobileNavMode: 'bottom_nav',
schema: { type: 'app', name: 'crm', navigation: NAV },
});
const bar = container.querySelector('[role="navigation"][aria-label="Mobile navigation"]');
expect(bar).not.toBeNull();
expect(Array.from(bar!.querySelectorAll('a')).map((a) => a.getAttribute('href'))).toEqual(['/apps/crm/account']);
// Lit control: `drawer`, the default, draws no bar.
const drawer = draw({ type: 'app-schema-renderer', schema: { type: 'app', name: 'crm', navigation: NAV } });
expect(drawer.querySelector('[aria-label="Mobile navigation"]')).toBeNull();
});

it('a node without `schema` draws the empty shell, as an omitted optional input: no error banner', () => {
// The node `skills/objectui/guides/mobile.md` teaches carries no document.
const container = draw({ type: 'app-schema-renderer', mobileNavMode: 'bottom_nav' });
expect(container.querySelector('[data-sidebar="sidebar"]')).not.toBeNull();
expect(container.textContent).not.toContain(FAILED_TO_RENDER);
});

it('the registration publishes `schema` as an object input, and the arm declares it', () => {
const inputs =
(ComponentRegistry.getConfig('app-schema-renderer', 'layout') as { inputs?: Array<{ name: string; type: string }> } | undefined)
?.inputs ?? [];
expect(inputs.find((input) => input.name === 'schema')?.type).toBe('object');
expect(Object.keys(AppSchemaRendererNodeSchema.shape)).toContain('schema');
});
});
Loading
Loading