From 1efc419069065384dcf81ed6fcb2253f7d27637e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:00:05 +0000 Subject: [PATCH] fix(record): register the record:* blocks under one key, prefixed once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven blocks in plugin-detail were registered as register('record:x', …, { namespace: 'record' }) — an already-prefixed name handed to a registry that prefixes it again. Each landed at record:record:x, and the key authors actually resolved, record:x, was the un-namespaced FALLBACK rather than the intended registration. The registry carried 23 keys for 12 components. Nothing failed, which is why it survived: getPublicConfigs() rewrites `type` to the curated tag, so the doubled name never reached the contract, the manifest, or the JSX surface. It was visible only when enumerating the registry directly — which is what #3013's reverse check does. Registering the bare name is what makes `namespace` correct, and skipFallback: true is what keeps the fallback from claiming that bare name globally. Without it these would take over `details`, `path`, `history`, `alert` … as top-level tags; `alert` is the live case, owned by ui:. Every block stays reachable exactly as record:, and 23 keys become 12. record:line_items needed no change — it was the one already registered this way, which is what made #3006's near-miss possible in the first place. Two console assertions hold the shape: no key carries a doubled prefix, and no record:* block owns the bare spelling of its own name. Both were verified against the old form: restoring one registration fails them with the phantom key named. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N4mrr1ihhwnfEHFSWmGoMp --- .changeset/record-namespace-double-prefix.md | 30 ++++++++++++ .../src/__tests__/public-contract.test.ts | 49 ++++++++++--------- packages/plugin-detail/src/index.tsx | 44 ++++++++++++----- 3 files changed, 90 insertions(+), 33 deletions(-) create mode 100644 .changeset/record-namespace-double-prefix.md diff --git a/.changeset/record-namespace-double-prefix.md b/.changeset/record-namespace-double-prefix.md new file mode 100644 index 0000000000..0275b5d3e7 --- /dev/null +++ b/.changeset/record-namespace-double-prefix.md @@ -0,0 +1,30 @@ +--- +"@object-ui/plugin-detail": patch +"@object-ui/console": patch +--- + +fix(record): register the record:\* blocks under one key, prefixed once + +Eleven blocks in plugin-detail were registered as +`register('record:x', …, { namespace: 'record' })` — an already-prefixed name +handed to a registry that prefixes it again. Each landed at +`record:record:x`, and the key authors actually resolved, `record:x`, was the +un-namespaced *fallback* rather than the intended registration. The registry +carried 23 keys for 12 components. + +Nothing failed, which is why it survived: `getPublicConfigs()` rewrites `type` +to the curated tag, so the doubled name never reached the contract, the +manifest, or the JSX surface. It was visible only when enumerating the registry +directly — which is what objectui#3013's reverse check does. + +Registering the bare name is what makes `namespace` correct, and +`skipFallback: true` is what keeps the fallback from claiming that bare name +globally. Without it these would take over `details`, `path`, `history`, +`alert` … as top-level tags; `alert` is the live case, owned by `ui:`. Every +block stays reachable exactly as `record:`, and 23 keys become 12. + +`record:line_items` needed no change — it was the one already registered this +way, which is what made objectui#3006's near-miss possible in the first place. + +Two console assertions hold the shape: no key carries a doubled prefix, and no +`record:*` block owns the bare spelling of its own name. diff --git a/apps/console/src/__tests__/public-contract.test.ts b/apps/console/src/__tests__/public-contract.test.ts index dab4da7a80..0be4dff74b 100644 --- a/apps/console/src/__tests__/public-contract.test.ts +++ b/apps/console/src/__tests__/public-contract.test.ts @@ -174,28 +174,10 @@ const DELIBERATELY_UNCURATED: Record = { describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => { const NS = 'record'; - /** - * Eleven of these blocks are registered as - * `register('record:x', …, { namespace: 'record' })` — an already-prefixed - * name that gets prefixed again — so the registry holds both `record:x` and a - * doubled `record:record:x` for the same component. The doubling never - * reaches the contract (`getPublicConfigs()` rewrites `type` to the curated - * tag), but it is real in the registry, and counting raw keys would report - * those components twice. Collapsing the repeated prefix is what makes the - * set below one entry per block. - */ - const undouble = (key: string): string => { - let k = key; - while (k.startsWith(`${NS}:${NS}:`)) k = k.slice(NS.length + 1); - return k; - }; - - const shippedRecordBlocks = (): string[] => { - const keys = ComponentRegistry.getKnownTypes().filter( - (k) => ComponentRegistry.getMeta(k)?.namespace === NS, - ); - return [...new Set(keys.map(undouble))].sort(); - }; + const shippedRecordBlocks = (): string[] => + ComponentRegistry.getKnownTypes() + .filter((k) => ComponentRegistry.getMeta(k)?.namespace === NS) + .sort(); it('curates every shipped record:* block, or records why not', () => { const curated = new Set(PUBLIC_BLOCKS); @@ -204,6 +186,29 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => { expect(uncurated).toEqual(Object.keys(DELIBERATELY_UNCURATED).sort()); }); + 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); + }); + + it('leaves the bare names to whoever owns them', () => { + // The corollary of registering bare + `namespace`: without + // `skipFallback: true` these also claim `details`, `path`, `alert` … as + // top-level tags. `alert` is the live example — it belongs to `ui:`, and a + // missing skipFallback here would silently take it over. + for (const tag of shippedRecordBlocks()) { + const bare = tag.slice(NS.length + 1); + expect(ComponentRegistry.getMeta(bare)?.namespace ?? null).not.toBe(NS); + } + }); + it('keeps the deliberately-uncurated blocks unconfigurable', () => { // The stated reason for every exclusion is "nothing to author". If one of // these grows `inputs`, it became authorable and the exclusion needs diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index ed0d08811c..1d5cc9444c 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -221,10 +221,22 @@ ComponentRegistry.register('detail', DetailView, { // These renderers consume RecordContext (provided by app-shell's // RecordDetailView) and adapt the spec's `RecordXxxComponentProps` onto the // legacy plugin-detail components above. +// +// Pass the BARE name and let `namespace` do the prefixing — `register('details', +// …, { namespace: 'record' })`, not `register('record:details', …)`. The +// registry prepends the namespace itself, so a pre-prefixed name is prefixed +// again and lands under `record:record:details`; the key that actually worked +// was the un-namespaced fallback, which happened to spell `record:details`. +// +// `skipFallback: true` is what keeps that fallback from claiming the bare name +// globally: without it these would register `details`, `path`, `history`, +// `alert` … as top-level tags, colliding with the `ui:` primitives that own +// them. Every block here is reachable as `record:` and only that. // --------------------------------------------------------------------------- -ComponentRegistry.register('record:details', RecordDetailsRenderer, { +ComponentRegistry.register('details', RecordDetailsRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Record Details', icon: 'FileText', @@ -237,8 +249,9 @@ ComponentRegistry.register('record:details', RecordDetailsRenderer, { ], }); -ComponentRegistry.register('record:related_list', RecordRelatedListRenderer, { +ComponentRegistry.register('related_list', RecordRelatedListRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Related List', icon: 'List', @@ -256,8 +269,9 @@ ComponentRegistry.register('record:related_list', RecordRelatedListRenderer, { ], }); -ComponentRegistry.register('record:highlights', RecordHighlightsRenderer, { +ComponentRegistry.register('highlights', RecordHighlightsRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Highlights Panel', icon: 'Star', @@ -268,15 +282,17 @@ ComponentRegistry.register('record:highlights', RecordHighlightsRenderer, { ], }); -ComponentRegistry.register('record:activity', RecordActivityRenderer, { +ComponentRegistry.register('activity', RecordActivityRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Activity Timeline', icon: 'Activity', }); -ComponentRegistry.register('record:chatter', RecordChatterRenderer, { +ComponentRegistry.register('chatter', RecordChatterRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Chatter Feed', icon: 'MessageSquare', @@ -287,15 +303,17 @@ ComponentRegistry.register('record:chatter', RecordChatterRenderer, { // share the same DiscussionContext wiring; we keep `record:chatter` // for Salesforce-familiar authors and for backward compatibility with // schemas already in the wild. -ComponentRegistry.register('record:discussion', RecordChatterRenderer, { +ComponentRegistry.register('discussion', RecordChatterRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Discussion', icon: 'MessageSquare', }); -ComponentRegistry.register('record:path', RecordPathRenderer, { +ComponentRegistry.register('path', RecordPathRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Path / Stepper', icon: 'GitBranch', @@ -306,29 +324,33 @@ ComponentRegistry.register('record:path', RecordPathRenderer, { ], }); -ComponentRegistry.register('record:quick_actions', RecordQuickActionsRenderer, { +ComponentRegistry.register('quick_actions', RecordQuickActionsRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Quick Actions', icon: 'Zap', }); -ComponentRegistry.register('record:history', RecordHistoryRenderer, { +ComponentRegistry.register('history', RecordHistoryRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'History Timeline', icon: 'Clock', }); -ComponentRegistry.register('record:reference_rail', RecordReferenceRailRenderer, { +ComponentRegistry.register('reference_rail', RecordReferenceRailRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Reference Rail', icon: 'PanelRight', }); -ComponentRegistry.register('record:alert', RecordAlertRenderer, { +ComponentRegistry.register('alert', RecordAlertRenderer, { namespace: 'record', + skipFallback: true, category: 'record', label: 'Alert Banner', icon: 'AlertTriangle',