From 641dcea68a1963253baac36553ed1a0668e5c4d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 08:50:13 +0000 Subject: [PATCH] ComponentRendererProps: re-export the one declaration instead of restating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core hand-declared a non-generic `ComponentRendererProps` typing `schema` as `SchemaNode`, against `@object-ui/types`' generic `ComponentRendererProps< TSchema extends BaseSchema = BaseSchema >` — the second such pair in the same file, two lines below `SchemaNode`. Core's becomes a re-export, per objectui#4580's ruling for the identical shape and objectui#4972 for `ComponentInput`, and the deliberate "not reconciled" marker comes out with it. Zero consumers re-verified repo-wide on the merged ref, not inherited from the card. The re-export is NOT surface-neutral: resolved through the TypeScript checker from core/dist/index.d.ts over a clean rebuild of both legs, `schema` narrows from the `SchemaNode` union back to `TSchema` (default `BaseSchema`) and the type gains a parameter. `core/dist/index.d.ts` byte-identity is not cited as evidence — it is an `export *` barrel and cannot fail for this change class (objectui#5673). A compile-time pin in @object-ui/react, the only position resolving both packages through node_modules, holds the reconciliation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK --- .../componentrendererprops-reexport-4594.md | 36 ++++++ packages/core/src/types/index.ts | 62 ++++++++-- ...ponentRendererProps.reconciliation.test.ts | 117 ++++++++++++++++++ 3 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 .changeset/componentrendererprops-reexport-4594.md create mode 100644 packages/react/src/__tests__/ComponentRendererProps.reconciliation.test.ts diff --git a/.changeset/componentrendererprops-reexport-4594.md b/.changeset/componentrendererprops-reexport-4594.md new file mode 100644 index 0000000000..52cad5745a --- /dev/null +++ b/.changeset/componentrendererprops-reexport-4594.md @@ -0,0 +1,36 @@ +--- +'@object-ui/core': minor +--- + +`ComponentRendererProps` is now declared once and re-exported, instead of +hand-declared a second time in `@object-ui/core` (objectui#4594). + +`@object-ui/core`'s `ComponentRendererProps` (`src/types/index.ts`) was a +non-generic interface typing `schema` as `SchemaNode`, while +`@object-ui/types`' declaration of the same name is generic — +`ComponentRendererProps< TSchema extends BaseSchema = BaseSchema >` with +`schema: TSchema`. Same name, both exported from their package entry, from two +packages the same consumers import together: which declaration a call site got +depended on which package it reached for, and the two disagree about whether a +primitive node is admissible. Core's is now a re-export of types', which is the +disposition objectui#4580 ruled for `SchemaNode` two lines above it in the same +file, and objectui#4972 for `ComponentInput` — *a structural copy would +reproduce the defect the moment either side moved*. + +**Published-surface effect, and the reason it is not neutral.** Resolved +through the TypeScript checker from `core/dist/index.d.ts` over a clean rebuild +of both legs, `ComponentRendererProps` as reached through `@object-ui/core` +moves from non-generic with +`schema: BaseSchema | string | number | boolean | null | undefined` to +`ComponentRendererProps` with `schema: TSchema`, defaulting to +`BaseSchema`. `schema` therefore **narrows** back to the object form — core's +copy had silently widened when objectui#4608 made core's `SchemaNode` a +re-export of types' union — and the type gains a parameter. **Nothing imported +it**, on either side, re-verified repo-wide on the merged ref, so no call site +can observe either move; the narrowing is recorded here because it is a change +to a published type, not because a consumer is affected. + +A compile-time pin now holds the reconciliation from +`@object-ui/react` — the only position that resolves both packages through +`node_modules` — alongside the existing `SchemaNode` one. It is a test-only +addition and emits nothing, so `@object-ui/react` takes no bump of its own. diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index a060b496fa..dfa836373b 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -40,17 +40,55 @@ */ export type { SchemaNode } from '@object-ui/types'; -import type { SchemaNode } from '@object-ui/types'; - /** - * ⛔ Deliberately NOT reconciled with `@object-ui/types`' `ComponentRendererProps` - * (objectui#4594). The two declarations differ — types' is generic - * (`< TSchema extends BaseSchema = BaseSchema >`), this one is not — but that - * card measured **zero consumers** of this declaration, so reconciling it here - * would be an unmeasured change riding a card that was scoped to `SchemaNode`. - * It stays dual-declared until #4594 is dispatched on its own evidence. + * One `ComponentRendererProps` (objectui#4594). + * + * This package used to hand-declare `interface ComponentRendererProps + * { schema: SchemaNode; [key: string]: any }` here — two lines below `SchemaNode` + * and against `@object-ui/types`' generic + * `ComponentRendererProps< TSchema extends BaseSchema = BaseSchema >`. Same name, + * two packages the same consumers import together: the second such pair in this + * one file, and the third across this fault line after `SchemaNode` (#4580) and + * `ComponentInput` (#4972). + * + * **`@object-ui/types`' generic declaration wins**, and is RE-EXPORTED rather + * than restated, so there is exactly one declaration left to disagree with — a + * structural copy would reproduce the defect the moment either side moved. + * + * Reconciled now, at **zero consumers** — re-verified on the merged ref rather + * than inherited from the card: repo-wide, `ComponentRendererProps` occurred only + * at the two declarations, each package's own entry re-export, and one line of + * `packages/components/CHANGELOG.md` recording that nothing used it. Zero + * consumers is why this is cheap today and would not stay cheap: the day someone + * imports it, which declaration they get decides whether a primitive node is + * admissible. + * + * ## What the re-export moves, and how that was measured + * + * It is NOT surface-neutral, and the gauge that would have said so is vacuous. + * `core/src/index.ts` is a 95-line `export *` barrel; `core/dist/index.d.ts` is + * therefore byte-identical under ANY change to a re-exported module and cannot + * fail for this change class (objectui#5673 — which is also why the sentence + * above `SchemaNode` citing that byte-identity is not repeated here). + * + * Measured instead by resolving the symbol through the TypeScript CHECKER from + * `core/dist/index.d.ts`, over a `dist/` + `tsconfig.tsbuildinfo` clean rebuild + * on both legs (the build info lives outside `dist/`, so composite `tsc` skips + * emit if it survives and two stale trees compare equal for free): + * + * ``` + * before ComponentRendererProps schema: BaseSchema | string | number | boolean | null | undefined + * after ComponentRendererProps schema: TSchema (TSchema extends BaseSchema = BaseSchema) + * ``` + * + * So `schema` NARROWS back to the object form — core's copy had silently widened + * when #4608 made `SchemaNode` a re-export of types' union, which is the interim + * state this card closes — and the type gains a parameter. Nothing downstream + * can observe either move, because nothing imports it. + * + * The collision is only observable from a package that resolves BOTH through + * `node_modules`; the pin therefore lives in `@object-ui/react` + * (`src/__tests__/ComponentRendererProps.reconciliation.test.ts`), not here — + * same position, same reason, as `SchemaNode`'s pin above. */ -export interface ComponentRendererProps { - schema: SchemaNode; - [key: string]: any; -} +export type { ComponentRendererProps } from '@object-ui/types'; diff --git a/packages/react/src/__tests__/ComponentRendererProps.reconciliation.test.ts b/packages/react/src/__tests__/ComponentRendererProps.reconciliation.test.ts new file mode 100644 index 0000000000..0d4de08304 --- /dev/null +++ b/packages/react/src/__tests__/ComponentRendererProps.reconciliation.test.ts @@ -0,0 +1,117 @@ +/** + * 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. + */ + +/** + * One `ComponentRendererProps` (objectui#4594). + * + * Two packages published a type of this name and they were not the same type: + * + * `@object-ui/core` `interface ComponentRendererProps { schema: SchemaNode; … }` + * `@object-ui/types` `interface ComponentRendererProps< TSchema extends BaseSchema = BaseSchema > { schema: TSchema; … }` + * + * The second such pair in one file — it sat two lines below `SchemaNode`, whose + * reconciliation #4580/PR #4608 settled — and the third across this fault line + * once `ComponentInput` (#4972/PR #5671) is counted. `@object-ui/types`' generic + * declaration wins and core's becomes a re-export, so there is exactly one + * declaration left to disagree with. + * + * ## Why this file lives in `@object-ui/react` + * + * Same reason as `SchemaNode.reconciliation.test.ts` next door: the pin has to + * be a CONSUMER of both packages, resolving each through `node_modules`. This + * package's `tsconfig.test.json` sets `"paths": {}` precisely so `@object-ui/*` + * resolve through the workspace dependency's built `.d.ts` rather than pulling + * sibling sources in as program inputs — which is what makes the two `dist` + * identities real rather than an artefact of the source tree. + * + * `ComponentRendererProps` had **zero consumers** when this was written (the + * measurement #4594 was dispatched on, re-verified on the merged ref). This + * file is therefore the type's first consumer, and deliberately so: a name that + * nothing imports is a name nothing can hold still, and the interim state the + * card closes — core's `schema` silently widened to types' `SchemaNode` union + * when #4608 landed — arrived exactly that way, with no consumer to notice. + * + * ## Predictions, written before the first run (red-first) + * + * With core's declaration restored to the hand-written non-generic interface, + * `tsc -p packages/react/tsconfig.test.json` must report: + * + * 1. `assertion1` — `Equal< CoreProps, TypesProps >` resolves `false` + * (core's `schema` is the `SchemaNode` union, types' is `BaseSchema`), so + * `Expect< … >` fails its `extends true` constraint: **TS2344**. + * 2. `assertion2` / `narrowedSchemaArmCompiles` — a type argument applied to + * core's name: **TS2315**, `Type 'ComponentRendererProps' is not generic`. + * 3. `narrowedSchemaArmCompiles` again, at the read — core's `schema` is the + * union, which is not assignable to a `BaseSchema` sub-interface: **TS2322**. + * + * After the fix all three compile clean. The equality assertion is deliberately + * INVARIANT (`Equal`, not `extends`): the index signature `[key: string]: any` + * makes almost anything mutually assignable, so a one-way `extends` — or a bare + * `satisfies` — would stay green against a structural copy, which is the whole + * defect. + */ + +import { describe, it, expect } from 'vitest'; +import type { ComponentRendererProps as CoreProps } from '@object-ui/core'; +import type { + ComponentRendererProps as TypesProps, + BaseSchema, +} from '@object-ui/types'; + +/* ── Type-level helpers ──────────────────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/** A concrete arm, to prove the type parameter is reachable through core's name. */ +interface TextSchema extends BaseSchema { + type: 'text'; + value?: string; +} + +/* ── 1. The two names are now one type ───────────────────────────────────── */ + +export type assertion1 = Expect< Equal< CoreProps, TypesProps > >; + +/* ── 2. Core's name carries the type parameter, with the same default ────── */ + +export type assertion2 = Expect< Equal< CoreProps< TextSchema >, TypesProps< TextSchema > > >; +export type assertion3 = Expect< Equal< CoreProps, TypesProps< BaseSchema > > >; + +/* ── 3. The parameter actually narrows `schema`, read through core's name ── */ + +/** + * ⚠️ Inside a never-called function on purpose — the sibling `SchemaNode` pin + * records why: `declare`d values are type-level fictions, so a top-level + * `const` of one throws `ReferenceError` the moment vitest imports the module + * and fails the whole suite before a case runs. A function body is checked by + * `tsc` just as thoroughly and never executes, which is what lets one file be + * read by both tools. Nothing here is `declare`d today, but the constraint is + * the file's, not the expression's. + */ +export function narrowedSchemaArmCompiles(): void { + const props: CoreProps< TextSchema > = { schema: { type: 'text', value: 'Hello' } }; + // Pre-fix this read is the union, not the arm: TS2322. + const node: TextSchema = props.schema; + void node; +} + +/* ── Runtime companion ───────────────────────────────────────────────────── */ + +describe('ComponentRendererProps is declared once (objectui#4594)', () => { + it('type-level: core and types name the same type', () => { + // The assertions above are erased at runtime — `tsc -p tsconfig.test.json` + // is what checks them, and this package chains that from `type-check`. + // This case documents that the pin is compile-time, so a reader does not + // mistake a green vitest run for the proof. + const witness: CoreProps< TextSchema > = { schema: { type: 'text' } }; + expect(witness.schema.type).toBe('text'); + }); +});