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
30 changes: 30 additions & 0 deletions .changeset/record-namespace-double-prefix.md
Original file line number Diff line number Diff line change
@@ -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:<name>`, 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.
49 changes: 27 additions & 22 deletions apps/console/src/__tests__/public-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,28 +174,10 @@ const DELIBERATELY_UNCURATED: Record<string, string> = {
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<string>(PUBLIC_BLOCKS);
Expand All @@ -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
Expand Down
44 changes: 33 additions & 11 deletions packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` and only that.
// ---------------------------------------------------------------------------

ComponentRegistry.register('record:details', RecordDetailsRenderer, {
ComponentRegistry.register('details', RecordDetailsRenderer, {
namespace: 'record',
skipFallback: true,
category: 'record',
label: 'Record Details',
icon: 'FileText',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
Loading