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
36 changes: 36 additions & 0 deletions .changeset/componentrendererprops-reexport-4594.md
Original file line numberDiff line numberDiff line change
@@ -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<TSchema>` 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.
62 changes: 50 additions & 12 deletions packages/core/src/types/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<TSchema> 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';
Original file line numberDiff line numberDiff line change
@@ -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');
});
});
Loading