Skip to content
Merged
93 changes: 93 additions & 0 deletions .changeset/9533-dashboard-bare-key-converge-plugin-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
'@object-ui/plugin-dashboard': minor
'@object-ui/cli': minor
---

Converge the bare `dashboard` key on `plugin-dashboard`, and retire
`view:dashboard` with a by-name tombstone (objectui#9533).

**Breaking for authored metadata.** `{ "type": "view:dashboard" }` no longer
renders a dashboard. It now resolves to a refusal that names the spelling and
names its replacement, and `objectui check` reports it as an unknown schema
type. `{ "type": "dashboard" }` and `{ "type": "plugin-dashboard:dashboard" }`
both render `DashboardRenderer`, unchanged and newly-working respectively.

**What was wrong.** `apps/console` declares the lazy stub for bare `dashboard`
under `plugin-dashboard` — twice, in `preview-gallery.tsx` and
`register-plugins.ts` — while this package registered the renderer as
`view:dashboard`. Neither site passed `skipFallback`, so both also claimed the
bare key (`Registry.register` and `Registry.registerLazy` share the
`meta?.namespace && !meta?.skipFallback` branch, and `registerLazy` has no
collision check at all, so nothing warned). Two consequences followed, and both
were reproduced against a real `Registry` before anything here was written:

1. **The bare key was double-claimed, phase-dependently.** After the stub step
bare `dashboard` declared namespace `plugin-dashboard`; after the chunk
loaded the same key declared `view`. Which answer a host got depended on when
it asked — the objectui#6416 shape, which converged `plugin-report` off it.
2. **The namespaced stub was never cleared.** `register()` clears the lazy stub
of the type IT registers, and that type was `view:dashboard`. So
`hasLazy('dashboard', 'plugin-dashboard')` read `true` after the package had
fully loaded and `get('dashboard', 'plugin-dashboard')` read `undefined`,
while the generated CLI whitelist listed that spelling as renderable. A node
authored with it could only ever paint `Loading plugin-dashboard:dashboard…`
forever: `Registry.loadLazy` resolves whether or not the loaded module
registered the expected type, and `SchemaRenderer` re-checks `hasLazy` on
every pass. That is the objectui#8760 shape — a key that passes every
authoring check and fails only in front of a user.

**What changed.** The renderer registers as `plugin-dashboard:dashboard`, the
namespace every sibling plugin, both console stubs and the CLI whitelist already
use, so the console stubs are cleared on load and all three claimants of the bare
key name ONE full type. The retired `view:dashboard` key answers
`RetiredDashboardNodeTombstone` — an inline refusal, plus a `console.error`
carrying the same text — registered with `skipFallback: true` so it claims no
bare key. Its spelling is withheld from the derived key universe by declaration
in `scripts/check-doc-component-types.mjs`, the same disposition the
`RETIRED_FIELD_TYPES` tombstones take, so `objectui check` names it rather than
blessing it; `packages/cli/src/utils/known-schema-types.ts` regenerates and loses
that one entry.

**Also published: four new symbols on the package entry.** `src/index.tsx`
re-exports `RETIRED_DASHBOARD_NODE_TYPES`, `RetiredDashboardNodeTombstone`,
`reportRetiredDashboardNodeType` and `resetRetiredDashboardNodeTypeReports` from
`./retired-node-types`, and `exports["."]` is what publishes them — so these are
**published** surface, not an internal one, and removing or renaming one later is
a breaking change like any other export. They exist for the same reason
`@object-ui/fields` publishes its tombstone: the pin has to import the refusal
text it asserts rather than restate it.

**Also visible in the DOM: the `data-obj-type` value on a dashboard grid.** That
attribute carries the type the node was AUTHORED with, so the spelling that puts
a dashboard grid on the page moves with the registration.
`[data-obj-type="view:dashboard"]` no longer selects one — that node now renders
the tombstone, which publishes `data-retired-node-type` and no `data-obj-type` at
all — while `[data-obj-type="plugin-dashboard:dashboard"]` now does.
`{ "type": "dashboard" }` is unchanged and still reads `dashboard`. A stylesheet,
a selector or a DOM assertion keyed on the retired value stops matching, and
stops matching silently, so it is declared here rather than left to be found.

**Ruled, not chosen.** Director seat summon #24, batch #152 item 5 letter 1,
maintainer 「其他同意」 2026-09-18. The two rejected routes are on the record:
converging on `view` would leave the whitelist advertising a dead
`plugin-dashboard:dashboard`, and standing one side down resolves the naming
contest but not the unsatisfiable stub.

**`Clause-②: yes`**, declared by that ruling: the published full name moves. Note
the shape, because it is unusual — the accept set **narrows** (an authored
`view:dashboard` stops resolving) and the clause is `yes` anyway, on the
published-name move rather than on a widened surface. `minor` per AGENTS.md's
版本号策略: objectui's own breaking changes take `minor`, with the breaking
semantics written out here.

`packages/plugin-dashboard/src/__tests__/dashboardBareKeyOwnership.test.tsx` is
the half that outlives the fix. It reads the package's real declared metadata
back out of the registry and replays it into a fresh `Registry` in **both**
orders — eager-then-lazy and lazy-then-eager — checking the bare key's declared
namespace after every step, so order- and phase-independence are properties under
test rather than properties of the file it imports. It also asserts `hasLazy` is
false after the load, and asserts the `view:dashboard` refusal **together with**
the tombstone's own migration text, because a pin that asserts only "no dashboard
rendered" passes identically against a registration that was simply deleted —
the one outcome the ruling refuses. `objectui#9264`'s shrink-only bare-name
collision ledger loses its row: it is now empty.
24 changes: 19 additions & 5 deletions content/docs/plugins/plugin-dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,13 @@ import '@object-ui/plugin-dashboard';
### What the side-effect import registers

That single import is the whole of registration — there is no components map to
iterate over. Importing the entry runs the eight `ComponentRegistry.register(...)`
iterate over. Importing the entry runs the eight live `ComponentRegistry.register(...)`
calls in `packages/plugin-dashboard/src/index.tsx`, which claim exactly these
schema types. The keys below are read off those calls.

| Namespaced key | Bare-name fallback | Renderer behind it |
| --- | --- | --- |
| `view:dashboard` | `dashboard` | `DashboardRenderer` — the widget container |
| `plugin-dashboard:dashboard` | `dashboard` | `DashboardRenderer` — the widget container |
| `plugin-dashboard:metric` | `metric` | `MetricWidget` — one KPI value |
| `plugin-dashboard:metric-card` | `metric-card` | `MetricCard` — KPI with trend and icon |
| `plugin-dashboard:object-metric` | `object-metric` | internal wrapper around `ObjectMetricWidget` — a metric aggregated over an object |
Expand All @@ -107,9 +107,23 @@ schema types. The keys below are read off those calls.

`ComponentRegistry.register` publishes `namespace:type`, and — unless the call
passes `skipFallback: true` — the bare `type` as a back-compat fallback
(`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). No
call in this package passes `skipFallback`, so each type above resolves under
both spellings; the schema snippets on this page use the bare one.
(`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). Every
call behind the table above leaves `skipFallback` unset, so each type there
resolves under both spellings; the schema snippets on this page use the bare one.

⛔ One further registration is deliberately absent from the table above. Until
objectui#9533 the dashboard renderer was published as `view:dashboard`, while
`apps/console`'s lazy stubs and the CLI's known-type whitelist already spelled it
`plugin-dashboard:dashboard`; the bare `dashboard` key therefore declared one
namespace before the chunk loaded and the other after it, and the
`plugin-dashboard:dashboard` stub was never cleared, so that spelling could never
resolve. The renderer now registers under `plugin-dashboard`, and the retired
`view:dashboard` key is answered by a tombstone widget that refuses BY NAME and
names `plugin-dashboard:dashboard` as its replacement — so an authored
`view:dashboard` gets a visible refusal carrying its own migration, never a
silent fall-through. That tombstone registration passes `skipFallback: true`, so
it claims no bare key, and `view:dashboard` is deliberately NOT a renderable key:
`objectui check` reports it as unknown.

The two `object-*` rows name a wrapper rather than an export on purpose.
`ObjectMetricBlock` and `ObjectPivotBlock` are internal to `src/index.tsx`: each
Expand Down
18 changes: 15 additions & 3 deletions packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
* - `plugin-chatbot:chatbot` / `chatbot-enhanced` — 14 attributes each,
* objectui#4431 / PR #4485, which also lifted `toDomProps` to
* `@object-ui/core` so later cards consume one executor.
* - `view:dashboard` — `DashboardRenderer`'s widget-grid container, 13
* - `plugin-dashboard:dashboard` (spelled `view:dashboard` until
* objectui#9533) — `DashboardRenderer`'s widget-grid container, 13
* attributes, objectui#4432 / this file's most recent edit.
*
* The three packages now reading 0 are NOT clean for the same reason, and the
Expand Down Expand Up @@ -875,7 +876,9 @@ const TARGETS: Readonly<Record<string, readonly Target[]>> = {
{ type: 'plugin-dashboard:object-pivot', schemaExtras: { objectName: 'accounts' }, ready: '[data-testid="pivot-empty-state"]' },
{ type: 'plugin-dashboard:object-data-table', schemaExtras: { objectName: 'accounts' }, ready: '[data-testid="table-empty-state"]' },
// `DashboardRenderer`'s widget grid — the element its `{...props}` lands on.
{ type: 'view:dashboard', ready: '.grid.auto-rows-min' },
// Spelled `view:dashboard` until objectui#9533 converged the bare `dashboard`
// key onto this package's own namespace; the renderer is the same one.
{ type: 'plugin-dashboard:dashboard', ready: '.grid.auto-rows-min' },
],
// objectui#5574 — 159 targets, built above rather than spelled here because
// 140 of them need nothing but the shared readiness class.
Expand Down Expand Up @@ -916,7 +919,16 @@ const OWNED_VIEW_ALIASES: Readonly<Record<string, readonly string[]>> = {
'plugin-charts': ['view:chart'],
'plugin-calendar': ['view:calendar'],
'plugin-chatbot': [],
'plugin-dashboard': ['view:dashboard'],
// objectui#9533 — EMPTY, and measured rather than defaulted. This package's
// dashboard renderer moved out of the shared `view:` namespace onto
// `plugin-dashboard:dashboard`, so it is covered by the prefix case above.
// `view:dashboard` is still a registered key, but what answers it is a
// retirement TOMBSTONE (a refusal alert naming the migration), not a widget:
// it destructures nothing and spreads nothing onto its element, so there is no
// pass-through surface for this sweep to measure. Its contract — the refusal
// and the text of it — is pinned in
// `packages/plugin-dashboard/src/__tests__/dashboardBareKeyOwnership.test.tsx`.
'plugin-dashboard': [],
};

/* ════════════════════════════════════════════════════════════════════════════
Expand Down
126 changes: 126 additions & 0 deletions packages/cli/src/__tests__/retired-view-dashboard-key-9533.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* 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 check` on the two spellings objectui#9533 moved — the
* AUTHORING-TIME half of that convergence, on the published surface.
*
* ## Why this pin and not only the derivation's
*
* `scripts/__tests__/known-schema-types-derivation-5115.test.ts` holds the
* generated snapshot against the derivation, which is the MECHANISM. This holds
* the outcome an author observes from outside the repository, in both
* directions, because objectui#9533 moved a key in each:
*
* - `plugin-dashboard:dashboard` was WHITELISTED AND UNRENDERABLE. The console
* declared the lazy stub under that name while the package registered
* `view:dashboard`, so `register()` never cleared the stub and nothing was
* ever stored under it. This command was silent on a document that could
* only ever paint `Loading plugin-dashboard:dashboard…` — the objectui#8760
* shape, whose grading is the line this pin sits on: "an unknown key is
* refused loudly at authoring time; a registered-but-unfulfilled key passes
* every check, is taught by the documentation, and fails only at render in
* front of a user."
* - `view:dashboard` was the package's published full name and is now RETIRED.
* It stays registered at runtime — a tombstone that refuses by name and
* names its replacement — but its keys are WITHHELD from the derived
* universe by declaration (`INDIRECT_REGISTRATIONS` in
* `scripts/check-doc-component-types.mjs`, the disposition objectui#9717
* holds open), precisely so that this command NAMES it instead of blessing
* it. Blessing it here would rebuild the defect the card removed, one
* spelling over.
*
* ## The controls
*
* Silence is one of the two readings under test, so the silent cases must be
* individually non-zero — a command that judged nothing at all would pass them.
* Bare `dashboard` and `plugin-dashboard:dashboard` are each asserted to be IN
* the shipped vocabulary before their documents are checked.
*
* Fixtures live under `os.tmpdir()` and carry the `className` marker, for the
* two reasons `check-known-types.test.ts` states at length: a fixture inside
* this workspace would be scanned by the repo's own `pnpm check`, and a file
* with no ObjectUI marker key is not judged at all (objectui#5127) — which would
* leave the silence assertions green while measuring nothing.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

import { check } from '../commands/check.js';
import { KNOWN_SCHEMA_TYPES } from '../utils/known-schema-types.js';

let cwd: string;
let lines: string[];
let restoreLog: () => void;

/** The escape byte chalk opens a CSI sequence with, spelled rather than typed. */
const ESC = String.fromCharCode(27);
const ANSI = new RegExp(`${ESC}\\[[0-9;]*m`, 'g');

function writeSchema(name: string, type: string): void {
writeFileSync(join(cwd, name), JSON.stringify({ className: 'p-0', type, widgets: [] }));
}

function unknownTypeWarnings(): string[] {
return lines.map((l) => l.replace(ANSI, '')).filter((l) => l.includes('Unknown schema type'));
}

beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'objectui-check-9533-'));
lines = [];
const original = console.log;
console.log = (...args: unknown[]) => {
lines.push(args.map(String).join(' '));
};
restoreLog = () => {
console.log = original;
};
});

afterEach(() => {
restoreLog();
rmSync(cwd, { recursive: true, force: true });
});

describe('objectui#9533 — the retired `view:dashboard` spelling is named, not blessed', () => {
it('`objectui check` reports it by name', async () => {
writeSchema('retired.json', 'view:dashboard');
await check(cwd);
expect(unknownTypeWarnings()).toEqual([
expect.stringContaining('Unknown schema type "view:dashboard" in retired.json'),
]);
});

it('it is gone from the shipped vocabulary', () => {
// Non-vacuity for the assertion beside it: the snapshot must be a real,
// populated set, or "does not contain" is true of everything.
expect(KNOWN_SCHEMA_TYPES.length).toBeGreaterThan(100);
expect(KNOWN_SCHEMA_TYPES).not.toContain('view:dashboard');
});
});

describe('objectui#9533 — CONTROL: the spellings that now really resolve stay silent', () => {
it.each(['dashboard', 'plugin-dashboard:dashboard'])(
'`%s` is in the vocabulary and draws no warning',
async (type) => {
// Individually non-zero: each names a key the plugin really registers.
// `plugin-dashboard:dashboard` was in this list BEFORE the fix too — what
// changed is that it now names a component rather than a stub nothing ever
// fulfilled, which is why the runtime half is pinned next to the plugin
// (`packages/plugin-dashboard/src/__tests__/dashboardBareKeyOwnership.test.tsx`)
// and not here.
expect(KNOWN_SCHEMA_TYPES).toContain(type);
writeSchema('live.json', type);
await check(cwd);
expect(unknownTypeWarnings()).toEqual([]);
},
);
});
1 change: 0 additions & 1 deletion packages/cli/src/utils/known-schema-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [
'view-switcher',
'view:calendar',
'view:chart',
'view:dashboard',
'view:detail',
'view:drawer',
'view:filter-ui',
Expand Down
24 changes: 19 additions & 5 deletions packages/plugin-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ const schema = {
### What the side-effect import registers

That single import is the whole of registration — there is no components map to
iterate over. Importing the entry runs the eight `ComponentRegistry.register(...)`
iterate over. Importing the entry runs the eight live `ComponentRegistry.register(...)`
calls in `src/index.tsx`, which claim exactly these schema types. The keys below
are read off those calls:

| Namespaced key | Bare-name fallback | Renderer behind it |
| --- | --- | --- |
| `view:dashboard` | `dashboard` | `DashboardRenderer` — the widget container |
| `plugin-dashboard:dashboard` | `dashboard` | `DashboardRenderer` — the widget container |
| `plugin-dashboard:metric` | `metric` | `MetricWidget` — one KPI value |
| `plugin-dashboard:metric-card` | `metric-card` | `MetricCard` — KPI with trend and icon |
| `plugin-dashboard:object-metric` | `object-metric` | internal wrapper around `ObjectMetricWidget` — aggregates over an object |
Expand All @@ -83,13 +83,27 @@ are read off those calls:

`ComponentRegistry.register` publishes `namespace:type`, and — unless the call
passes `skipFallback: true` — the bare `type` as a back-compat fallback
(`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). No
call in this package passes `skipFallback`, so each type above resolves under
both spellings. The two `object-*` types are served by internal wrappers that
(`packages/core/src/registry/Registry.ts:194`, fallback branch at `:226`). Every
call behind the table above leaves `skipFallback` unset, so each type there
resolves under both spellings. The two `object-*` types are served by internal wrappers that
first resolve the spec's per-element `dataSource` binding (through
`ElementDataSourceGate` from `@object-ui/react`) and then render the exported
component, which is why those rows name a wrapper rather than an export.

⛔ One further registration is deliberately absent from the table above. Until
objectui#9533 the dashboard renderer was published as `view:dashboard`, while
`apps/console`'s lazy stubs and the CLI's known-type whitelist already spelled it
`plugin-dashboard:dashboard`; the bare `dashboard` key therefore declared one
namespace before the chunk loaded and the other after it, and the
`plugin-dashboard:dashboard` stub was never cleared, so that spelling could never
resolve. The renderer now registers under `plugin-dashboard`, and the retired
`view:dashboard` key is answered by a tombstone widget that refuses BY NAME and
names `plugin-dashboard:dashboard` as its replacement — so an authored
`view:dashboard` gets a visible refusal carrying its own migration, never a
silent fall-through. That tombstone registration passes `skipFallback: true`, so
it claims no bare key, and `view:dashboard` is deliberately NOT a renderable key:
`objectui check` reports it as unknown.

### Registering a component under your own key

To serve one of this package's components under a key of your own, register the
Expand Down
Loading
Loading