diff --git a/.changeset/page-kind-node-type-channel-9642.md b/.changeset/page-kind-node-type-channel-9642.md new file mode 100644 index 0000000000..75885d076e --- /dev/null +++ b/.changeset/page-kind-node-type-channel-9642.md @@ -0,0 +1,19 @@ +--- +--- + +Declare the page-kind to node-type channel (objectui#9642) — comments and one +pin test only, no published behaviour moves, so this declares "not shipping" +rather than a bump. + +A stored page document's `type` field is the spec's page KIND, and the app-shell +page view writes it verbatim into the SchemaNode discriminator that the +component registry dispatches on. The page-kind registrations on `PageRenderer` +exist because of that line. Nothing said so at either place an auditor arrives, +so two cards read those registrations as "registered but never declared" and one +of them reached a draft PR that would have stopped every stored kind-`app` page +rendering. The channel is now stated at both reading ends and pinned by a test +that names it when it breaks. + +Nothing authorable changes: no registration is added, removed or renamed, no +accept set moves, no name joins a published barrel, and the upstream page-kind +enum is read and never edited. diff --git a/packages/app-shell/src/views/PageView.tsx b/packages/app-shell/src/views/PageView.tsx index e7fc1e73fc..046e3ded6c 100644 --- a/packages/app-shell/src/views/PageView.tsx +++ b/packages/app-shell/src/views/PageView.tsx @@ -127,6 +127,18 @@ export function PageView() { // so non-record pages got the record max-width, a wrong // `data-page-type` and a suppressed header (framework#1878 §3 // naming-drift recheck). + // + // ⭐ This is the WRITING end of the page-kind to node-type + // channel, and a comment here was not enough: two cards audited + // the READING end and concluded the registrations it feeds were + // undeclared (objectui#9263, re-ruled letter E "⛔ not a + // defect", and objectui#9576). The channel is now declared at + // both reading ends — `@object-ui/types`' `SchemaRegistry` map + // at its `'page'` entry, and the `PageRenderer` registrations in + // `@object-ui/components` — and pinned by + // `page-kind-node-type-channel-9642` (objectui#9642). ⛔ Change + // this mapping and that pin goes red by design: it is the + // declaration, not an incidental assertion. type: (page as any).type || 'page', pageType: (page as any).type, context: { ...(page as any).context, params, refreshKey }, diff --git a/packages/components/src/__tests__/page-kind-node-type-channel-9642.test.tsx b/packages/components/src/__tests__/page-kind-node-type-channel-9642.test.tsx new file mode 100644 index 0000000000..a2ed655735 --- /dev/null +++ b/packages/components/src/__tests__/page-kind-node-type-channel-9642.test.tsx @@ -0,0 +1,226 @@ +/** + * 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#9642 — THE PAGE-KIND to NODE-TYPE CHANNEL, declared. + * + * ## The channel + * + * A stored page document's `type` field is the spec's page KIND, enumerated by + * `PageTypeSchema` in `@objectstack/spec/ui`. `PageView` in `@object-ui/app-shell` + * hands that document to `SchemaRenderer` with the kind written VERBATIM into + * `type` — the SchemaNode discriminator `ComponentRegistry` dispatches on — and + * a copy on `pageType` (the comment there opens "`type` stays the SchemaNode + * discriminator ComponentRegistry dispatches on"). + * + * ⇒ The `ComponentRegistry.register(..., PageRenderer, ...)` calls in + * `renderers/layout/page.tsx` exist BECAUSE of that line. They are not a + * component family; they are the renderer half of `PageTypeSchema`, which is + * why one of them is labelled "App Page". + * + * ## Why this file exists rather than another comment + * + * A comment stating the channel was already present at the writing end, and two + * cards were nevertheless reasoned to the opposite conclusion from the reading + * end: an auditor who opens the registry sees names registered with no schema + * declaring them and concludes "registered but never declared" — a real defect + * shape in this repository. objectui#9263 ruled on that reading and reached a + * draft PR that would have stopped every stored kind-`app` page rendering + * (OBJUI-001 in place of the page); objectui#9576 proposed the same disposition + * for the other kinds. objectui#9263 was re-ruled letter E — "⛔ not a defect" + * — and re-attributed the account to this undeclared channel. + * + * A comment cannot fail. This file can, and the assertions below are written so + * that the failure NAMES the channel instead of looking like a component bug. + * + * ## ⭐ `app` is ONE TOKEN carrying TWO VOCABULARIES (objectui#9642 scope note) + * + * `AppComponentSchema` (`@object-ui/types`) declares the type literal `'app'` + * for the APP-LEVEL DOCUMENT (`app.json`: tabs, navigation, areas), which the + * runner / layout path reads STRUCTURALLY — it is never resolved through + * `ComponentRegistry`, and `@object-ui/types`' `SchemaRegistry` map has no + * `'app'` key. + * + * The spec page kind `app` is a different vocabulary: a stored PAGE document + * with regions, served by `PageRenderer` through `PageView`'s passthrough, and + * the registry key `'app'` answers for THAT one alone. + * + * ⇒ Two vocabularies sharing one token. Neither is a duplicate of the other and + * ⛔ neither may be removed as a collision with the other. The `registryAppKeyServesThePageKind` + * case below is the mechanical half of this paragraph. + * + * ## What this file deliberately does NOT do + * + * It does ⛔ not change the render path. Making page kinds stop riding the node + * discriminator (`PageView` mapping every kind to `type: 'page'` plus + * `pageType`) moves every stored document's render path; that option was in + * front of the maintainer on objectui#9263 and is ⛔ not this card's to pick. + * ⛔ No registration is added, removed or renamed here, and the upstream enum is + * read, never edited. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { PageTypeSchema } from '@objectstack/spec/ui'; +// Module scope, not a hook: a cold `await import()` inside a hook is billed to +// `hookTimeout` and races the assertions (AGENTS.md §测试纪律, objectui#3010). +// This import is what puts the page registrations into `ComponentRegistry`. +import '../renderers'; +import { PageRenderer } from '../renderers/layout/page'; + +/** + * The page kinds whose documents reach `ComponentRegistry` at all. + * + * ⚠️ NOT every member of `PageTypeSchema` does. An INTERFACE-MODE kind is + * short-circuited before the renderer: `PageView` branches on + * `interfaceConfig?.source` and renders `InterfaceListPage` directly, "rendered + * directly, not via regions" in its own words, so `SchemaRenderer` is never + * reached and the kind needs no registry key. Today `list` is the only such + * kind — every stored `list` page carries `interfaceConfig.source`, which is + * ADR-0047 interface mode. + * + * ⇒ A kind listed here must resolve to `PageRenderer`; a kind listed below in + * `INTERFACE_MODE_KINDS` must NOT. The union of the two must be exactly + * `PageTypeSchema.options`, and the case that checks that is what goes red when + * the upstream enum grows a kind nothing serves. + */ +const INTERFACE_MODE_KINDS = ['list'] as const; + +/** + * ⚠️ `'page'` is NOT a member of `PageTypeSchema` — the spec refuses it. + * + * It is the FALLBACK half of the same two-line mapping: `PageView` writes + * `type: (page as any).type || 'page'`, so a stored document with no `type` at + * all still resolves. It is also the key `PageNodeSchema` pins (`z.literal('page')`) + * and the only page key `@object-ui/types`' `SchemaRegistry` map declares. + */ +const FALLBACK_NODE_TYPE = 'page'; + +function pageKinds(): string[] { + // `PageTypeSchema` is a lazy schema; `.options` is the enum's member list. + return [...(PageTypeSchema as unknown as { options: readonly string[] }).options]; +} + +describe('objectui#9642 — page kind ↔ node type channel', () => { + it('every region-composed page kind resolves to PageRenderer', () => { + const served = pageKinds().filter( + (kind) => !(INTERFACE_MODE_KINDS as readonly string[]).includes(kind), + ); + + // Firing control: the set under test is non-empty, so a green below is a + // reading and not an empty loop. + expect(served.length).toBeGreaterThan(0); + + for (const kind of served) { + expect( + ComponentRegistry.get(kind), + `page kind '${kind}' is a member of @objectstack/spec PageTypeSchema and PageView writes it ` + + `verbatim into the SchemaNode discriminator, so ComponentRegistry must answer it with ` + + `PageRenderer. It does not. Either the registration was removed — every stored '${kind}' ` + + `page then renders OBJUI-001 instead of the page (objectui#9263) — or the upstream enum ` + + `grew a kind nothing serves. ⛔ Do not close this by deleting the kind from the fixture.`, + ).toBe(PageRenderer); + } + }); + + it('interface-mode page kinds are NOT served by PageRenderer', () => { + for (const kind of INTERFACE_MODE_KINDS) { + // Firing control on this instrument: the key IS registered, to some other + // renderer. Without this line an unregistered key would read `undefined` + // and satisfy `.not.toBe(PageRenderer)` — absence scoring as a reading. + expect( + ComponentRegistry.get(kind), + `node type '${kind}' is expected to be registered to a NON-page renderer; reading undefined ` + + `means this probe measured nothing, ⛔ not that the kind is unserved.`, + ).toBeDefined(); + + expect( + ComponentRegistry.get(kind), + `page kind '${kind}' is interface mode: PageView branches on interfaceConfig.source and ` + + `renders InterfaceListPage directly, so this kind never reaches the registry as a page. ` + + `Something now answers '${kind}' with PageRenderer — the channel's shape changed and the ` + + `declaration in this file's header has to be re-derived before this is made green.`, + ).not.toBe(PageRenderer); + } + }); + + it('the registry key set over page kinds is exactly the enum, split two ways', () => { + const kinds = pageKinds(); + const servedByRenderer = kinds.filter((kind) => ComponentRegistry.get(kind) === PageRenderer); + const interfaceMode = kinds.filter((kind) => + (INTERFACE_MODE_KINDS as readonly string[]).includes(kind), + ); + + expect( + [...servedByRenderer, ...interfaceMode].sort(), + `every member of PageTypeSchema must be accounted for exactly once: served by PageRenderer, ` + + `or short-circuited by PageView's interface-mode branch. A kind in neither column is a ` + + `false affordance — it validates at authoring time and breaks at runtime, which the ` + + `PageTypeSchema docblock calls "especially dangerous when templates are AI-authored".`, + ).toEqual([...kinds].sort()); + }); + + it('the page-kind registrations are absent from the published ComponentType union, deliberately', async () => { + // `keyof SchemaRegistry` IS the published `ComponentType` union, and that + // map's own docblock makes its key set "the Single Source of Truth for + // component type lookups". The page KINDS are deliberately not keys there: + // adding one would widen `ComponentType`, which is a ruling and not a + // by-product of this declaration (objectui#9642 Clause-②: no). + // + // ⇒ This case pins the ASYMMETRY that mis-framed objectui#9263 and + // objectui#9576, so that a future reader meets it as a checked fact instead + // of re-deriving "registered but never declared" from the gap. + const kinds = pageKinds(); + const servedByRenderer = kinds.filter((kind) => ComponentRegistry.get(kind) === PageRenderer); + + expect(servedByRenderer.length).toBeGreaterThan(0); + + expect( + ComponentRegistry.get(FALLBACK_NODE_TYPE), + `'${FALLBACK_NODE_TYPE}' is the untyped-document fallback PageView writes and the only page ` + + `key the SchemaRegistry map declares; it must keep resolving to PageRenderer.`, + ).toBe(PageRenderer); + + expect( + kinds.includes(FALLBACK_NODE_TYPE), + `'${FALLBACK_NODE_TYPE}' must NOT be a member of PageTypeSchema — the spec refuses it, and a ` + + `page kind by that name would collide with the fallback this channel depends on.`, + ).toBe(false); + }); + + it('registryAppKeyServesThePageKind — one token, two vocabularies', () => { + // The scope note on objectui#9642, mechanised. `AppComponentSchema`'s `app` + // is the app-level document, read structurally by the runner / layout path + // and never resolved through this registry; the registry key `'app'` answers + // only for the spec PAGE KIND `app`, through PageView's passthrough. + expect( + PageTypeSchema.safeParse('app').success, + `'app' must stay a member of PageTypeSchema — the registry key below exists to serve it.`, + ).toBe(true); + + expect( + ComponentRegistry.get('app'), + `the registry key 'app' answers for the spec PAGE KIND 'app' (a stored page document with ` + + `regions, served by PageRenderer through PageView's passthrough), ⛔ NOT for ` + + `AppComponentSchema's 'app' (the app-level document, read structurally by the runner and ` + + `layout path). Two vocabularies share this one token; ⛔ neither is a collision to be ` + + `resolved by removing the other — that reading produced objectui#9263 and objectui#9576.`, + ).toBe(PageRenderer); + + // Firing control on the same instrument: an ordinary component key resolves + // to something OTHER than PageRenderer, so `.toBe(PageRenderer)` above is a + // discriminating read and not a tautology over a registry that answers + // PageRenderer for everything. + expect(ComponentRegistry.get('grid')).toBeDefined(); + expect(ComponentRegistry.get('grid')).not.toBe(PageRenderer); + + // Absent-token control: an unregistered key reads undefined, so a zero from + // this instrument means absence rather than a broken probe. + expect(ComponentRegistry.get('zzz-not-a-registered-node-type')).toBeUndefined(); + }); +}); diff --git a/packages/components/src/__tests__/page-single-h1.test.tsx b/packages/components/src/__tests__/page-single-h1.test.tsx index 96d7ba935e..97e3b12ecf 100644 --- a/packages/components/src/__tests__/page-single-h1.test.tsx +++ b/packages/components/src/__tests__/page-single-h1.test.tsx @@ -32,7 +32,20 @@ import { SchemaRenderer } from '@object-ui/react'; // `hookTimeout` and races the assertions (AGENTS.md §测试纪律, objectui#3010). import '../renderers'; -/** The showcase master-detail page, reduced to the parts that render headings. */ +/** + * The showcase master-detail page, reduced to the parts that render headings. + * + * ⚠️ `type: 'app'` below is EVIDENCE about a real stored document, ⛔ not a + * convenience (objectui#9642). It is the spec page KIND, which the app-shell + * page view writes verbatim into the SchemaNode discriminator, so this fixture + * exercises the page-kind to node-type channel as the shipped page does. On + * objectui#9263 it was edited to `'page'` so a change removing the `'app'` + * registration would keep passing — that PR was closed without merging and the + * card was re-ruled letter E, "⛔ not a defect". ⇒ If a change turns this file + * red, read `page-kind-node-type-channel-9642` in this directory before + * touching the value: a fixture that mirrors a real document is a reading, and + * editing it to agree with a change destroys the signal instead of taking it. + */ function masterDetailPage(headerProps: Record | null, extra?: Record) { return { type: 'app', diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx index 6fdf1e8dde..48fb71622b 100644 --- a/packages/components/src/renderers/layout/page.tsx +++ b/packages/components/src/renderers/layout/page.tsx @@ -708,6 +708,46 @@ const pageMeta: any = { ], }; +/** + * ⭐ THESE KEYS ARE PAGE KINDS, NOT A COMPONENT FAMILY — read this before + * auditing them (objectui#9642). + * + * A stored page document's `type` field is the spec's page KIND, enumerated by + * `PageTypeSchema` in `@objectstack/spec/ui`. `PageView` (`@object-ui/app-shell`) + * hands that document to `SchemaRenderer` with the kind written VERBATIM into + * `type` — the SchemaNode discriminator `ComponentRegistry` dispatches on — + * plus a copy on `pageType`. ⇒ The registrations below exist BECAUSE of that + * line. They are the renderer half of `PageTypeSchema`, which is why they carry + * "… Page" labels rather than component names, and `'page'` is the fallback the + * same mapping writes for a document carrying no `type` at all. + * + * ⛔ **They are therefore NOT the "registered but never declared" defect** this + * repository files elsewhere. They ARE declared — upstream, in a different + * vocabulary, by an enum this package cannot edit. `@object-ui/types`' + * `SchemaRegistry` map has no key for them on purpose, because `keyof` that map + * is the published `ComponentType` union and widening it is a ruling; that map + * carries the other half of this note at its `'page'` entry. + * + * ⚠️ Removing one of these registrations stops every stored page of that kind + * rendering — OBJUI-001 in place of the page. objectui#9263 reached a draft PR + * doing exactly that and was re-ruled letter E, "⛔ not a defect"; objectui#9576 + * proposed the same for the remaining kinds. + * + * ⭐ **`app` is one token carrying two vocabularies.** `AppComponentSchema` + * (`@object-ui/types`) declares the type literal `'app'` for the APP-LEVEL + * DOCUMENT (`app.json`: tabs, navigation, areas), which the runner / layout path + * reads STRUCTURALLY and never resolves through this registry. The key below + * answers only for the spec PAGE KIND `app` — a stored page document with + * regions, reached through `PageView`'s passthrough. ⛔ Neither is a collision + * to be resolved by removing the other. + * + * ⚠️ Not every page kind appears below, and the absence is not an omission: an + * INTERFACE-MODE kind is short-circuited before this registry, because `PageView` + * branches on `interfaceConfig?.source` and renders `InterfaceListPage` + * directly. The live split — which kind is served here, which is short-circuited + * — is re-derived by `page-kind-node-type-channel-9642` in this package's + * `__tests__`, ⛔ not by this comment. + */ ComponentRegistry.register('page', PageRenderer, pageMeta); ComponentRegistry.register('app', PageRenderer, { ...pageMeta, label: 'App Page' }); ComponentRegistry.register('utility', PageRenderer, { ...pageMeta, label: 'Utility Page' }); diff --git a/packages/types/src/registry.ts b/packages/types/src/registry.ts index 6f9e25063d..84ed58f492 100644 --- a/packages/types/src/registry.ts +++ b/packages/types/src/registry.ts @@ -151,6 +151,46 @@ export interface SchemaRegistry { 'tabs': TabsSchema; 'scroll-area': ScrollAreaSchema; 'resizable': ResizableSchema; + // ⭐ `'page'` is the ONLY page key in this map, and the four page KINDS that + // `ComponentRegistry` also answers — `record` / `home` / `app` / `utility` — + // are ABSENT from it deliberately. Read that gap here before concluding + // anything from it (objectui#9642, and objectui#9263 / objectui#9576, the two + // cards that concluded the opposite). + // + // **The channel.** A STORED page document's `type` field is not a node type at + // all: it is the spec's page KIND, enumerated by `PageTypeSchema` in + // `@objectstack/spec/ui`. `PageView` (`@object-ui/app-shell`) hands the + // document to `SchemaRenderer` with that kind written VERBATIM into `type` — + // the discriminator `ComponentRegistry` dispatches on — plus a copy on + // `pageType`. ⇒ The `PageRenderer` registrations in + // `@object-ui/components`' `renderers/layout/page.tsx` exist BECAUSE of that + // line; they are the renderer half of `PageTypeSchema`, which is why one of + // them is labelled "App Page". `'page'` itself is the fallback the same + // mapping writes for a document carrying no `type`, and it is the key + // `PageNodeSchema` pins (`z.literal('page')`). + // + // ⛔ So a `PageRenderer` key missing from this map is NOT the + // "registered but never declared" defect this repository files elsewhere: it + // is declared, upstream, in a different vocabulary. Removing such a + // registration stops every stored page of that kind rendering — objectui#9263 + // reached a draft PR doing exactly that and was re-ruled letter E, "⛔ not a + // defect". + // + // ⭐ **`app` is one token carrying two vocabularies.** `AppComponentSchema` + // (`./app.ts`) declares the type literal `'app'` for the APP-LEVEL DOCUMENT + // (`app.json`: tabs, navigation, areas), which the runner / layout path reads + // STRUCTURALLY and never resolves through `ComponentRegistry` — which is why + // this map has no `'app'` key for it either. The spec page kind `app` is a + // different vocabulary: a stored PAGE document with regions, served by + // `PageRenderer` through `PageView`'s passthrough. ⛔ Neither is a collision + // to be resolved by removing the other. + // + // ⚠️ Adding the kinds to this map is ⛔ NOT the remedy — `keyof SchemaRegistry` + // IS the published `ComponentType` union, so an entry here is a widening, and + // a widening is a ruling (the `'kanban'` note below states the same limit). + // The live answer to which kind is served how is re-derived by + // `page-kind-node-type-channel-9642` in `@object-ui/components`, ⛔ not by this + // paragraph. 'page': PageNodeSchema; // Form diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 8cea0a857d..13efd8312a 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -588,6 +588,30 @@ export const PageTypeSchema = stripImportedDefaults(SpecPageTypeSchema); * (`record|app|utility|list|home`), objectui's is the component * discriminator (`'page'`) and the kind lives on `pageType` below. * Reconciling the two is a rename decision tracked separately; + * + * ⚠️ **The collision is NOT resolved at runtime, and a reader of this bullet + * alone will get it wrong** (objectui#9642). `PageView` (`@object-ui/app-shell`) + * hands a stored page to `SchemaRenderer` with the KIND written VERBATIM into + * `type` and a copy on `pageType` — so the node that actually reaches + * `ComponentRegistry` carries `type: 'app'` / `'home'` / `'record'` / + * `'utility'`, which `PageNodeSchema`'s `z.literal('page')` below would + * REFUSE. ⇒ `@object-ui/components` registers those kinds as node types on + * `PageRenderer` precisely so the passthrough resolves; that registration set + * is the renderer half of the spec's `PageTypeSchema`, ⛔ not a component + * family and ⛔ not names registered without a schema. Two cards read it the + * second way (objectui#9263, re-ruled letter E "⛔ not a defect", and + * objectui#9576). + * + * ⭐ `app` is one token carrying two vocabularies: `AppComponentSchema`'s + * `'app'` is the APP-LEVEL DOCUMENT, read structurally by the runner / + * layout path and never resolved through `ComponentRegistry`; the spec page + * kind `app` is a stored PAGE document served by `PageRenderer` through that + * passthrough. ⛔ Neither is a collision to be resolved by removing the other. + * + * The live split — which kind is served by `PageRenderer`, which is + * short-circuited by `PageView`'s `interfaceConfig.source` branch — is + * re-derived by `page-kind-node-type-channel-9642` in `@object-ui/components`, + * ⛔ not by this bullet; * - `regions` — objectui's `PageNodeRegionSchema` adds `type`/`className` and * widens `width`; migration deferred (it is its own ledger entry). *