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
70 changes: 70 additions & 0 deletions .changeset/component-meta-converge-and-deprecate-alias-5893.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
---
'@object-ui/types': minor
---

`ComponentMeta` is now declared once and re-exported, and `PluginComponentMeta` is
deprecated in favour of `ComponentMeta` (objectui#5893).

## The convergence

`@object-ui/types` published `ComponentMeta` twice, from two different declarations:
`base.ts` and `plugin-scope.ts` (the latter published as `PluginComponentMeta`). They
were structural copies, not an alias pair. `plugin-scope.ts`' `ComponentMeta` is now
`export type { ComponentMeta } from './base.js'` — the disposition objectui#4580 ruled
for the identical shape, *a structural copy would reproduce the defect the moment either
side moved*, and the same move objectui#5671 made for the sibling type `ComponentInput`
in the same file.

Either side had already moved. `base.ts` declared eleven keys; the plugin-scoped copy
declared nine — the same nine, **minus `tags` and `description`**. So a plugin author
typing against the plugin-facing declaration could not write two keys the main surface
advertises, and which the runtime validator already accepted: `ComponentMetaSchema` in
`zod/base.zod.ts` declares all eleven, so two of the three authorities agreed and the
plugin-facing one did not. `resizeConstraints`' six members were identical in both, so
the delta was exactly those two keys.

What changes for a consumer: `tags` and `description` become writable on the
plugin-facing type. Nothing narrows — no key is removed and no key's type changes, so no
existing registration stops compiling. The convergence buys **acceptance** of two keys;
it buys no rejection of anything. `ComponentMetaSchema` is a plain `z.object` with no
`.strict()`, so it strips unknown keys rather than refusing them, and that is unchanged
here.

## The alias deprecation, sequenced after it

`PluginComponentMeta` — the published alias for the plugin-scoped declaration — is now
`@deprecated` in favour of `ComponentMeta`. **`PluginComponentMeta` is the name to search
for** if you import it; replace it with `ComponentMeta` from the same entry point.

This is stage 1 of objectui#5674's two-stage retirement (maintainer ruling, 2026-08-22:
deprecate for a release, then remove). Nothing is removed here — the export still exists
and still names the same type.

The ordering is deliberate and is why the two halves ship together. Until the convergence
above, the alias named a genuinely different nine-key interface; deprecating it then
would have warned consumers about a name that was still about to change meaning. It is
deprecated now, at its final meaning.

**Why a deprecation window rather than a deletion.** The measurement that licenses
deleting an export from a published package is *"no importer"*, and what can be measured
from inside this repository is only *"no importer here"*. In-repo, `PluginComponentMeta`
has exactly one occurrence — its own export line — searched across every root
(`packages/`, `apps/`, `content/`, `docs/`, `skills/`, `examples/`, `e2e/`, `scripts/`,
`eslint-rules/`, `public/`, `.changeset/` and the root docs) plus the sibling
`objectstack` framework checkout, with controls searched identically so a broken search
could not read as a clean one. What no search here can see is a consumer on npm. **That
external caveat is unchanged from objectui#5674 and is not being dropped:** the window
converts a silent break into a warned one before stage 2 lands. Stage 2 removes the alias
and the now-dead re-export in `plugin-scope.ts` that exists only to feed it, and ships as
a `minor` under this repo's policy that its own breaking changes never declare `major`.

## Pinned by identity, not by member set

A new test asserts that `plugin-scope.ts` re-exports the declaration and declares no
`ComponentMeta` of its own. A member-set assertion cannot do this job: TypeScript is
structurally typed, so a local re-declaration carrying the same eleven keys is mutually
assignable with the imported one and passes every type-level check. A member-identical
structural copy is exactly what objectui#4580 predicted would drift and exactly the state
this card recorded — this copy started identical and acquired its two-key delta later.
The member-set checks are kept alongside the identity pin, labelled as the control that
shows what it cannot see.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
/**
* 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.
*/

/**
* Convergence pin — `ComponentMeta` has ONE declaration (objectui#5893), and
* the second published name for it is deprecated.
*
* ## What was wrong
*
* `index.ts` published `ComponentMeta` twice, from two different declarations:
* `base.ts` (eleven keys) and `plugin-scope.ts` (nine — the same nine, minus
* `tags` and `description`). They were structural COPIES, not an alias pair.
* The runtime validator `ComponentMetaSchema` (`zod/base.zod.ts`) declares all
* eleven, so two of the three authorities agreed and the plugin-facing one did
* not: a plugin author typing against it could not write two keys the main
* surface and the validator both advertise.
*
* That is objectui#4580's ruling coming true rather than being cited — *"a
* structural copy would reproduce the defect the moment either side moved"* —
* and objectui#5671 had already executed the identical convergence for the
* sibling type `ComponentInput` in the same file.
*
* ## Why this pins the DIVERGENCE and not the resulting shape
*
* The obvious test — assert both spellings carry the same member set — is
* NOT sufficient, and the file below demonstrates why rather than asserting
* it. TypeScript is structurally typed: a local `interface ComponentMeta`
* re-declaring the same eleven keys is mutually assignable with the imported
* one, so every type-level check passes on it. A member-identical structural
* copy is EXACTLY the state objectui#4580 predicted would drift, and exactly
* the state this card is the proof of — the copy here started member-identical
* and acquired its two-key delta later.
*
* So the load-bearing assertion is an IDENTITY pin: `plugin-scope.ts` must
* RE-EXPORT the declaration and must not declare a `ComponentMeta` of its own.
* The member-set checks are kept alongside it, labelled, as the control that
* shows the identity pin catches something they cannot.
*
* ## Why the identity half reads SOURCE TEXT
*
* Two reasons, the first structural and the second practical.
*
* There is no type-level operator that distinguishes "the same declaration"
* from "an identical declaration" — structural identity is the whole point of
* TypeScript's type system, and `keyof`-based comparisons erase the difference
* by construction. The distinction survives only in the source and in the
* emitted `.d.ts` (a re-export line versus an `interface` body).
*
* And the emitted `.d.ts` is not available here: this repo's per-PR `test` job
* runs `pnpm test` with no build of the package under test ahead of it (turbo's
* `test` task `dependsOn: ["^build"]` — the DEPENDENCY closure, never the
* package's own build). A test requiring a fresh `dist/` would be vacuously
* absent-or-red on a cold cache, not a signal. Same constraint
* `plugin-component-input-deprecation.test.ts` and
* `package-exports-manifest.test.ts` record, same resolution.
*/

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, it, expect } from 'vitest';
import { ComponentMetaSchema } from '../zod/base.zod.js';
import type { ComponentMeta } from '../base.js';
import type { ComponentMeta as PluginScopeComponentMeta } from '../plugin-scope.js';
import type { ComponentMeta as IndexComponentMeta, PluginComponentMeta } from '../index.js';

const PLUGIN_SCOPE_SRC = readFileSync(
fileURLToPath(new URL('../plugin-scope.ts', import.meta.url)),
'utf8',
);

const INDEX_SRC = readFileSync(
fileURLToPath(new URL('../index.ts', import.meta.url)),
'utf8',
);

/** The re-export line that makes `plugin-scope.ts` share `base.ts`' declaration. */
const RE_EXPORT = "export type { ComponentMeta } from './base.js';";

/**
* Any LOCAL declaration of the name, exported or not. The re-export form
* (`export type { ComponentMeta } from …`) cannot match: the brace after
* `type` is not the identifier.
*/
const LOCAL_DECLARATION = /^\s*(?:export\s+)?(?:interface|type|class)\s+ComponentMeta\b/m;

describe('ComponentMeta — the identity pin (the assertion a structural copy fails)', () => {
it('plugin-scope.ts re-exports the declaration from base.ts', () => {
expect(PLUGIN_SCOPE_SRC).toContain(RE_EXPORT);
});

it('plugin-scope.ts declares no ComponentMeta of its own', () => {
// THIS is the pin. It goes red on a local re-declaration even when that
// re-declaration is member-identical — which is the case every type-level
// assertion below stays green on, and the case objectui#4580 ruled about.
expect(PLUGIN_SCOPE_SRC).not.toMatch(LOCAL_DECLARATION);
});

it('base.ts is the declaration site, so the pin is not vacuous', () => {
// Control for the regex itself: the same pattern MUST match where the one
// declaration actually lives. A pattern that matched nothing anywhere
// would pass the assertion above on any tree, including a re-diverged one.
const BASE_SRC = readFileSync(
fileURLToPath(new URL('../base.ts', import.meta.url)),
'utf8',
);
expect(BASE_SRC).toMatch(LOCAL_DECLARATION);
});
});

describe('ComponentMeta — the member-set control (green on a structural copy, kept to show the contrast)', () => {
it('is mutually assignable across both spellings', () => {
// Type-level, and really compiled: `packages/types/tsconfig.test.json` is
// chained from this package's `type-check` script (#3009). It is ALSO
// exactly what a member-identical local copy would satisfy — recorded here
// as the control, not as the guarantee.
const bothWays: [
PluginScopeComponentMeta extends ComponentMeta ? true : false,
ComponentMeta extends PluginScopeComponentMeta ? true : false,
] = [true, true];

expect(bothWays).toEqual([true, true]);
});

it('reaches the published entry point under both names', () => {
const published: [
PluginComponentMeta extends IndexComponentMeta ? true : false,
IndexComponentMeta extends PluginComponentMeta ? true : false,
] = [true, true];

expect(published).toEqual([true, true]);
});
});

describe('ComponentMeta — the two keys the convergence delivers', () => {
it('lets a plugin registration write `tags` and `description`', () => {
// The counter-probe the convergence has to survive: a consumer legitimately
// using the converged type must still type-check. "The duplicate is gone"
// is otherwise satisfiable by breaking the type for everyone.
//
// Before objectui#5893 the two annotated keys were a plain TS error on this
// spelling and legal on `base.ts`' — the divergence, at a call site.
const registration: PluginScopeComponentMeta = {
label: 'Kanban Board',
icon: 'layout-board',
category: 'data',
inputs: [{ name: 'columns', type: 'array' }],
isContainer: false,
resizable: true,
tags: ['board', 'kanban'],
description: 'Drag-and-drop board view over a grouped dataset.',
};

expect(registration.tags).toEqual(['board', 'kanban']);
expect(registration.description).toBe(
'Drag-and-drop board view over a grouped dataset.',
);
});

it('agrees with the runtime validator, which already carried both keys', () => {
// The zod mirror is the third authority and it never diverged: it declared
// `tags` and `description` throughout. The convergence brings the plugin
// face up to it rather than moving it.
//
// Note what this does NOT assert: `ComponentMetaSchema` is a plain
// `z.object` with no `.strict()`, so it STRIPS unknown keys rather than
// rejecting them (measured on zod 4.4.3 by
// `default-children-retired-contract-twins.test.ts`). The convergence buys
// ACCEPTANCE of two keys on the plugin-facing type; it buys no rejection of
// anything, here or anywhere else.
const parsed = ComponentMetaSchema.safeParse({
label: 'Kanban Board',
tags: ['board', 'kanban'],
description: 'Drag-and-drop board view over a grouped dataset.',
});

expect(parsed.success).toBe(true);
expect(parsed.data).toHaveProperty('tags', ['board', 'kanban']);
expect(parsed.data).toHaveProperty(
'description',
'Drag-and-drop board view over a grouped dataset.',
);
});
});

/**
* The JSDoc block immediately preceding an export specifier, or `null` when the
* specifier is not preceded by one. Anchored to the specifier rather than
* searching the file for `@deprecated`: this package has many unrelated
* deprecations and a file-wide search would go green on any of them.
*
* Same helper, same reasoning, as `plugin-component-input-deprecation.test.ts`.
*/
function docBlockBefore(specifier: string): string | null {
const at = INDEX_SRC.indexOf(specifier);
if (at === -1) return null;
const before = INDEX_SRC.slice(0, at);
const close = before.lastIndexOf('*/');
// Only whitespace may sit between the block and the specifier, otherwise the
// block belongs to some earlier specifier and says nothing about this one.
if (close === -1 || before.slice(close + 2).trim() !== '') return null;
const open = before.lastIndexOf('/**', close);
if (open === -1) return null;
return before.slice(open, close + 2);
}

/** The published alias specifier, exactly as `index.ts` spells it. */
const ALIAS = 'ComponentMeta as PluginComponentMeta,';

describe('PluginComponentMeta — stage 1: deprecated, at its final meaning', () => {
it('is still published (deprecating is not deleting)', () => {
// In-repo the name has zero importers; what cannot be measured from inside
// this repository is an importer on npm, and the window is the answer to
// that unmeasurable half. Stage 2 removes it.
expect(INDEX_SRC).toContain(ALIAS);
});

it('carries a JSDoc block attached to the alias specifier itself', () => {
expect(docBlockBefore(ALIAS)).not.toBeNull();
});

it('tags that block `@deprecated`', () => {
expect(docBlockBefore(ALIAS)).toContain('@deprecated');
});

it('names the replacement, so the warning is actionable', () => {
expect(docBlockBefore(ALIAS)).toMatch(/`ComponentMeta`/);
});

it('does not deprecate its neighbours in the same export block', () => {
// Control: the tag must attach to ONE specifier. A block comment widened
// to cover the whole `export type { … }` group would make every
// plugin-scope name read as deprecated.
expect(docBlockBefore('AppMetadataPlugin,')).toBeNull();
expect(docBlockBefore('PluginEventHandler,')).toBeNull();
});

it('is deprecated only AFTER the convergence — the ordering is the point', () => {
// Sequencing pin. Deprecating the alias while it still named a separate
// nine-key declaration would have warned consumers about a name that was
// about to change meaning. The tag is only honest because the re-export
// above it is in place, so the two facts are asserted together.
expect(PLUGIN_SCOPE_SRC).toContain(RE_EXPORT);
expect(docBlockBefore(ALIAS)).toContain('@deprecated');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,20 @@
* The Registry twin (`@object-ui/core`) is pinned in that package instead, so
* this suite does not have to import its own dependent.
*
* ⚠️ UPDATED by objectui#5893: item 3 is no longer a separate declaration.
* `plugin-scope.ts` now RE-EXPORTS `base.ts`' `ComponentMeta` (the objectui#4580
* convergence, following objectui#5671's execution for `ComponentInput`), so
* the third case below no longer exercises a second declaration — it exercises
* a second published SPELLING of the first, which is a real consumer-facing
* fact only for as long as the deprecated `PluginComponentMeta` alias exists.
* It is deliberately kept rather than deleted: while the alias is published, an
* author can still reach the type by that name, and the pin costs one
* `@ts-expect-error`. Drop it together with the alias in objectui#5674's
* stage 2. Whether the declaration stays single is pinned separately and by
* identity, in `component-meta-single-declaration.test.ts` — a member-set
* assertion cannot see a structural copy, which is the defect this file was
* paying for.
*
* Two kinds of assertion, deliberately different because the surfaces differ:
*
* The Zod half is NOT a refusal. Measured on zod 4.4.3, a `z.object` STRIPS
Expand DownExpand Up@@ -135,7 +149,10 @@ describe('the published TS twins no longer offer the retired key', () => {
const retired: PluginScopeComponentMeta = {
label: 'Span',
// @ts-expect-error `defaultChildren` was retired by objectui#5051; the
// plugin-facing twin spelt it `any[]` and is retired in lockstep.
// plugin-facing twin spelt it `any[]` and was retired in lockstep. Since
// objectui#5893 this spelling re-exports `base.ts`' declaration, so the
// error above is the SAME one the case above pins, reached by the second
// published name.
defaultChildren: [{ type: 'text', content: 'Inline text' }],
};
expect(retired.label).toBe('Span');
Expand Down
15 changes: 15 additions & 0 deletions packages/types/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -939,6 +939,21 @@ export type {
PluginScopeConfig,
AppPluginContext,
AppMetadataPlugin,
/**
* @deprecated Use `ComponentMeta` instead. Since objectui#5893 converged the
* plugin-scoped declaration onto `base.ts`, this alias names the SAME type
* under a second name — it carries no information `ComponentMeta` does not.
* Before that convergence it named a genuinely different nine-key interface,
* which is why it is deprecated only now and not alongside
* `PluginComponentInput`: deprecating it earlier would have warned about a
* name that was still about to change meaning. Retirement follows
* objectui#5674's two-stage pattern (maintainer ruling, 2026-08-22:
* deprecate for a release, then remove). This deprecation window exists for
* consumers outside this repository, which cannot be measured from here;
* in-repo the name has zero importers. Removal ships as a `minor` under this
* repo's version policy (objectui's own breaking changes never declare
* `major`).
*/
ComponentMeta as PluginComponentMeta,
/**
* @deprecated Use `ComponentInput` instead. Since objectui#4972 converged the
Expand Down
Loading
Loading