diff --git a/.changeset/namespace-double-prefix-sweep.md b/.changeset/namespace-double-prefix-sweep.md new file mode 100644 index 0000000000..1b63f3fbf1 --- /dev/null +++ b/.changeset/namespace-double-prefix-sweep.md @@ -0,0 +1,30 @@ +--- +"@object-ui/components": patch +"@object-ui/console": patch +--- + +fix(registry): prefix every namespaced key exactly once, in every namespace + +objectui#3023 fixed eleven `record:*` blocks registered as +`register('record:x', …, { namespace: 'record' })` — an already-prefixed name +handed to a registry that prefixes it again, landing the block at +`record:record:x` — and guarded that namespace alone. Twenty-two more were +sitting in `action:` (5), `element:` (10) and `page:` (7), two of them +(`page:header`, `element:divider`) curated public blocks. + +Checking one namespace is exactly what let them keep sitting there, so the +guard now asks the whole registry rather than a prefix of it. + +Same fix as before: register the bare name and let `namespace` do the +prefixing, with `skipFallback: true` so the fallback does not claim that bare +name globally. It would otherwise take over `header`, `footer`, `sidebar`, +`tabs`, `card`, `accordion`, `section`, `text`, `image`, `button`, `icon` — +every one of which belongs to `ui:`. All 22 stay reachable exactly as +`:`; the registry goes 522 keys to 500, and the contract is +unchanged at 42/42. + +Found while probing why six curated Tier B primitives report no `inputs`. They +do declare them — `vitest.setup.dom.tsx` registers simplified `text` / `image` / +`html` / `grid` stubs that shadow the real registrations inside the test +environment only. That shadowing is a separate question, left alone here; the +doubled keys it turned up are not test-environment artifacts. diff --git a/apps/console/src/__tests__/public-contract.test.ts b/apps/console/src/__tests__/public-contract.test.ts index e8aa91c522..6d9267b70f 100644 --- a/apps/console/src/__tests__/public-contract.test.ts +++ b/apps/console/src/__tests__/public-contract.test.ts @@ -202,14 +202,7 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => { }); it('registers each record:* block under one key, prefixed once', () => { - // `register('record:x', …, { namespace: 'record' })` prefixes an - // already-prefixed name: the block lands at `record:record:x` and stays - // reachable only through the un-namespaced fallback, which happens to spell - // `record:x`. Eleven blocks were registered that way. The contract hid it — - // `getPublicConfigs()` rewrites `type` to the curated tag — so nothing - // failed while the registry carried a phantom key per block. const keys = shippedRecordBlocks(); - expect(keys.filter((k) => k.startsWith(`${NS}:${NS}:`))).toEqual([]); expect(keys.every((k) => k.startsWith(`${NS}:`))).toBe(true); }); @@ -224,6 +217,27 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => { } }); + it('prefixes every namespaced key exactly once, in every namespace', () => { + // `register('page:header', …, { namespace: 'page' })` hands an + // already-prefixed name to a registry that prefixes it again: the block + // lands at `page:page:header` and stays reachable only through the + // un-namespaced fallback, which happens to spell `page:header`. Nothing + // fails — `getPublicConfigs()` rewrites `type` to the curated tag — so the + // registry quietly carries a phantom key per block. + // + // objectui#3023 fixed the eleven in `record:` and guarded that namespace + // alone. Twenty-two more were sitting in `action:`, `element:` and `page:`, + // two of them (`page:header`, `element:divider`) curated public blocks. + // Checking one namespace is what let them keep sitting there, so this asks + // the whole registry. + const doubled = ComponentRegistry.getKnownTypes().filter((k) => { + const ns = ComponentRegistry.getMeta(k)?.namespace; + return !!ns && k.startsWith(`${ns}:${ns}:`); + }); + + expect(doubled).toEqual([]); + }); + it('keeps the chatter alias identical to the block it aliases', () => { // `record:chatter` is excluded because it duplicates `record:discussion`, // not because it is lesser. The moment the two configuration surfaces diff --git a/packages/components/src/renderers/action/action-bar.tsx b/packages/components/src/renderers/action/action-bar.tsx index 2c4b5c5994..e265dc214d 100644 --- a/packages/components/src/renderers/action/action-bar.tsx +++ b/packages/components/src/renderers/action/action-bar.tsx @@ -282,8 +282,9 @@ const ActionBarRenderer = forwardRef( ActionButtonRenderer.displayName = 'ActionButtonRenderer'; -ComponentRegistry.register('action:button', ActionButtonRenderer, { +ComponentRegistry.register('button', ActionButtonRenderer, { namespace: 'action', + skipFallback: true, label: 'Action Button', inputs: [ { name: 'name', type: 'string', label: 'Action Name' }, diff --git a/packages/components/src/renderers/action/action-group.tsx b/packages/components/src/renderers/action/action-group.tsx index b4ad374755..8e57251867 100644 --- a/packages/components/src/renderers/action/action-group.tsx +++ b/packages/components/src/renderers/action/action-group.tsx @@ -294,8 +294,9 @@ const ActionGroupRenderer = forwardRef( ActionIconRenderer.displayName = 'ActionIconRenderer'; -ComponentRegistry.register('action:icon', ActionIconRenderer, { +ComponentRegistry.register('icon', ActionIconRenderer, { namespace: 'action', + skipFallback: true, label: 'Action Icon', inputs: [ { name: 'name', type: 'string', label: 'Action Name' }, diff --git a/packages/components/src/renderers/action/action-menu.tsx b/packages/components/src/renderers/action/action-menu.tsx index a3fc9b6149..0934f920fe 100644 --- a/packages/components/src/renderers/action/action-menu.tsx +++ b/packages/components/src/renderers/action/action-menu.tsx @@ -220,8 +220,9 @@ const ActionMenuRenderer = forwardRef; } -ComponentRegistry.register('element:divider', ElementDividerRenderer, { +ComponentRegistry.register('divider', ElementDividerRenderer, { namespace: 'element', + skipFallback: true, label: 'Divider', category: 'content', }); @@ -155,8 +157,9 @@ function ElementImageRenderer({ schema }: { schema: any }) { ); } -ComponentRegistry.register('element:image', ElementImageRenderer, { +ComponentRegistry.register('image', ElementImageRenderer, { namespace: 'element', + skipFallback: true, label: 'Image', category: 'content', }); @@ -260,8 +263,9 @@ function ElementButtonRenderer({ schema }: { schema: any }) { ); } -ComponentRegistry.register('element:button', ElementButtonRenderer, { +ComponentRegistry.register('button', ElementButtonRenderer, { namespace: 'element', + skipFallback: true, label: 'Button', category: 'action', }); @@ -381,8 +385,9 @@ function ElementNumberRenderer({ schema }: { schema: any }) { ); } -ComponentRegistry.register('element:number', ElementNumberRenderer, { +ComponentRegistry.register('number', ElementNumberRenderer, { namespace: 'element', + skipFallback: true, label: 'Number', category: 'content', }); diff --git a/packages/components/src/renderers/basic/metadata-viewer.tsx b/packages/components/src/renderers/basic/metadata-viewer.tsx index 130efdb102..1766dadc2b 100644 --- a/packages/components/src/renderers/basic/metadata-viewer.tsx +++ b/packages/components/src/renderers/basic/metadata-viewer.tsx @@ -379,8 +379,9 @@ export function ElementMetadataViewerRenderer({ schema }: { schema: any }) { } } -ComponentRegistry.register('element:metadata_viewer', ElementMetadataViewerRenderer, { +ComponentRegistry.register('metadata_viewer', ElementMetadataViewerRenderer, { namespace: 'element', + skipFallback: true, label: 'Metadata Viewer', category: 'content', }); diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index 0d5b4537e6..bc59d01357 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -173,8 +173,9 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { ); } -ComponentRegistry.register('element:record_picker', ElementRecordPickerRenderer, { +ComponentRegistry.register('record_picker', ElementRecordPickerRenderer, { namespace: 'element', + skipFallback: true, label: 'Record Picker', category: 'input', inputs: [ diff --git a/packages/components/src/renderers/basic/text-input.tsx b/packages/components/src/renderers/basic/text-input.tsx index 16d7c32282..5d184f9e15 100644 --- a/packages/components/src/renderers/basic/text-input.tsx +++ b/packages/components/src/renderers/basic/text-input.tsx @@ -126,8 +126,9 @@ function ElementTextInputRenderer({ schema }: { schema: any }) { ); } -ComponentRegistry.register('element:text_input', ElementTextInputRenderer, { +ComponentRegistry.register('text_input', ElementTextInputRenderer, { namespace: 'element', + skipFallback: true, label: 'Text Input', category: 'input', inputs: [ diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index bc1b764434..38ad4fcb36 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -553,8 +553,9 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { ); }; -ComponentRegistry.register('page:tabs', PageTabsRenderer, { +ComponentRegistry.register('tabs', PageTabsRenderer, { namespace: 'page', + skipFallback: true, label: 'Page Tabs', category: 'layout', isContainer: true, @@ -595,8 +596,9 @@ const PageCardRenderer: React.FC = ({ schema, className, ...props }) => { ); }; -ComponentRegistry.register('page:card', PageCardRenderer, { +ComponentRegistry.register('card', PageCardRenderer, { namespace: 'page', + skipFallback: true, label: 'Page Card', category: 'layout', isContainer: true, @@ -675,8 +677,9 @@ const PageAccordionRenderer: React.FC = ({ schema, className, ...props }) = ); }; -ComponentRegistry.register('page:accordion', PageAccordionRenderer, { +ComponentRegistry.register('accordion', PageAccordionRenderer, { namespace: 'page', + skipFallback: true, label: 'Page Accordion', category: 'layout', isContainer: true, @@ -698,8 +701,9 @@ const PageSectionRenderer: React.FC = ({ schema, className, ...props }) => ); }; -ComponentRegistry.register('page:section', PageSectionRenderer, { +ComponentRegistry.register('section', PageSectionRenderer, { namespace: 'page', + skipFallback: true, label: 'Page Section', category: 'layout', isContainer: true, @@ -1297,8 +1301,9 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { ); }; -ComponentRegistry.register('page:header', PageHeaderRenderer, { +ComponentRegistry.register('header', PageHeaderRenderer, { namespace: 'page', + skipFallback: true, label: 'Page Header', category: 'layout', }); @@ -1322,8 +1327,9 @@ const PageFooterRenderer: React.FC = ({ schema, className, ...props }) => { ); }; -ComponentRegistry.register('page:footer', PageFooterRenderer, { +ComponentRegistry.register('footer', PageFooterRenderer, { namespace: 'page', + skipFallback: true, label: 'Page Footer', category: 'layout', isContainer: true, @@ -1345,8 +1351,9 @@ const PageSidebarRenderer: React.FC = ({ schema, className, ...props }) => ); }; -ComponentRegistry.register('page:sidebar', PageSidebarRenderer, { +ComponentRegistry.register('sidebar', PageSidebarRenderer, { namespace: 'page', + skipFallback: true, label: 'Page Sidebar', category: 'layout', isContainer: true,