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
24 changes: 24 additions & 0 deletions .changeset/schemarenderer-open-contract-4548.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@object-ui/react': minor
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-report': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-view': patch
'@object-ui/components': patch
---

`SchemaRenderer` states its real contract — a typed, required `schema` and a deliberate forwarding surface

`SchemaRenderer` is the renderer loop: every registered SDUI component is rendered through it. It handed `forwardRef` a props type of `{ schema: SchemaNode } & Record<string, any>`, which puts `string` into `keyof Props`, so `'ref' extends keyof Props` was always true, React's `PropsWithoutRef` took its `Omit` branch, and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared prop was erased. Measured on the pre-fix source: `keyof ComponentProps<typeof SchemaRenderer>` was `string` and `ComponentProps<typeof SchemaRenderer>['schema']` was `any`, while the type argument went on declaring `SchemaNode`. The other half is the same defect seen from the call site — `<SchemaRenderer />` with no schema at all, `<SchemaRenderer schema={12345} />`, and an arbitrary misspelled prop each type-checked in silence. This is objectui#4422 / PR #4438's trap in the most central component in the repo, spelled `Record<string, any>` rather than `[key: string]: any`, which is why every previous sweep's grep and both shipped guards' detector reported the site as clean.

Graded **minor, not major**, on objectui#4528's reasoning: the type argument has always DECLARED `schema`; the index signature erased it from the resolved type, and restoring what the declaration documents is a fix to the published contract rather than a contract break.

**The forwarding surface is kept, deliberately.** This component forwards every prop it does not read to the component the schema names, resolved at runtime from a plugin-extensible registry — `packages/react/README.md` documents exactly that, and `@object-ui/components`' form renderer consumes the `onSubmit` it shows being forwarded. Closing that surface would state a false contract and would force every leaf plugin's props into this package. So the two halves are separated: the `forwardRef` type argument is the honest `SchemaRendererProps`, with no index signature for `PropsWithoutRef` to collapse, and the open surface is stated once in an explicit export annotation, which nothing routes through `Omit`. The published `.d.ts` shows the erasure disappearing: `ForwardRefExoticComponent<Omit<{ schema: SchemaNode } & Record<string, any>, "ref"> & RefAttributes<any>>` becomes `ForwardRefExoticComponent<SchemaRendererProps & Record<string, any> & RefAttributes<any>>`.

`SchemaRendererProps.schema` is declared as `BaseSchema | string | null | undefined` — what this component actually handles. It previously declared `@object-ui/core`'s `SchemaNode` interface, which requires `type: string` and so contradicted the component's own early returns for strings and nullish, while every caller held `@object-ui/types`' wider union. The erasure hid that mismatch completely.

**One declared behaviour change.** A non-object, non-string primitive schema now renders as its own text. It previously fell through to the shallow copy `{ ...schema }`, which spreads a primitive to an empty object, lost the `type` the renderer then looked up, and surfaced the red "Unknown component type: undefined" box — an accident of the spread rather than a decision. The declared props type excludes `number` / `boolean` so no author is invited to pass them; the runtime handling is defence-in-depth for untyped callers and stored metadata. Strings, `null`, `undefined`, `0` and `false` render exactly as before, and an object naming an unregistered type still gets the error box; all four are pinned.

Latent defects the erasure had been hiding, each surfaced by the repo-wide type-check and fixed at its call site: `DashboardRenderer` cast its widget schema to `Record<string, any>`, dropping the `type` every branch of `getComponentSchema` sets; `DashboardGridLayout`'s equivalent now states its return type instead of inferring a union that admitted a shape with no `type`; and `ReportViewer` handed a section's `content` array to the renderer whole, so a multi-node section rendered the unknown-component box instead of its content — arrays are mapped rather than widened into the renderer's declared input.

A repo-wide structural guard replaces the two per-package siblings' blocked direction: it judges every `forwardRef` in `packages/*/src` (219 sites) and its detector resolves `Record<string, …>` and `string`-keyed mapped types in addition to literal index signatures — the spelling the previous detector went blind on. It judges the type argument only, where an index signature is an accidental eraser, and never an export annotation, where one is a stated contract.
4 changes: 2 additions & 2 deletions packages/components/src/__tests__/html-anchor-links.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { SchemaRenderer, ActionProvider } from '@object-ui/react';
import type { NavigationHandler } from '@object-ui/core';
import type { SchemaNode } from '@object-ui/types';
import type { BaseSchema } from '@object-ui/types';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`, which is why this carried a raised
// timeout. See object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
Expand All @@ -30,7 +30,7 @@ function renderAnchor(
schema: Record<string, unknown>,
onNavigate?: NavigationHandler,
) {
const tree = <SchemaRenderer schema={{ type: 'a', ...schema } as SchemaNode} />;
const tree = <SchemaRenderer schema={{ type: 'a', ...schema } as BaseSchema} />;
return render(
onNavigate ? (
<ActionProvider onNavigate={onNavigate}>{tree}</ActionProvider>
Expand Down
8 changes: 4 additions & 4 deletions packages/components/src/renderers/layout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
*/

import React, { useMemo } from 'react';
import type { PageNodeSchema, PageNodeRegion, SchemaNode } from '@object-ui/types';
import { SchemaRenderer, PageVariablesProvider, PageVariableActionBridge } from '@object-ui/react';
import type { BaseSchema, PageNodeSchema, PageNodeRegion, SchemaNode } from '@object-ui/types';
import { SchemaRenderer, toRenderableSchema, PageVariablesProvider, PageVariableActionBridge } from '@object-ui/react';
import { ComponentRegistry } from '@object-ui/core';
import { compile, manifestFromConfigs } from '@object-ui/sdui-parser';
import { ReactKindPage } from './react-page';
Expand Down Expand Up @@ -179,7 +179,7 @@ const RegionContent: React.FC<{
data-region={region.name}
>
{components.map((node: SchemaNode, idx: number) => (
<SchemaRenderer key={(node as any)?.id || `${region.name}-${idx}`} schema={node} />
<SchemaRenderer key={(node as any)?.id || `${region.name}-${idx}`} schema={toRenderableSchema(node)} />
))}
</div>
);
Expand Down Expand Up @@ -557,7 +557,7 @@ export const PageRenderer: React.FC<{
</div>
);
}
return tree ? <SchemaRenderer schema={tree as unknown as SchemaNode} /> : null;
return tree ? <SchemaRenderer schema={tree as unknown as BaseSchema} /> : null;
}
const TemplateLayout = resolveTemplate(schema);
if (TemplateLayout) {
Expand Down
6 changes: 3 additions & 3 deletions packages/components/src/renderers/navigation/header-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { HeaderBarSchema, BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types';
import { resolveKeyedI18nLabel, SchemaRenderer } from '@object-ui/react';
import { resolveKeyedI18nLabel, SchemaRenderer, toRenderableSchema } from '@object-ui/react';
import {
SidebarTrigger,
Separator,
Expand Down Expand Up @@ -93,9 +93,9 @@ ComponentRegistry.register('header-bar',
</div>
)}
{schema.actions?.map((action, idx) => (
<SchemaRenderer key={idx} schema={action} />
<SchemaRenderer key={idx} schema={toRenderableSchema(action)} />
))}
{schema.rightContent && <SchemaRenderer schema={schema.rightContent} />}
{schema.rightContent && <SchemaRenderer schema={toRenderableSchema(schema.rightContent)} />}
</div>
</header>
),
Expand Down
13 changes: 11 additions & 2 deletions packages/plugin-dashboard/src/DashboardGridLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui
import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react';
import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react';
import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
import { isObjectProvider } from './utils';
import { classifyWidgetType } from './widgetDispatch';

Expand Down Expand Up @@ -378,7 +378,16 @@ export const DashboardGridLayout: React.FC<DashboardGridLayoutProps> = ({
>
{schema.widgets?.map((widget, index) => {
const widgetId = widget.id || `widget-${index}`;
const componentSchema = getComponentSchema(widget);
// `getComponentSchema` builds a node for `SchemaRenderer` in every
// branch, but its inferred union is wider than the renderer's
// declared input: the passthrough fallback spreads a
// `DashboardWidgetSchema` whose `type` is OPTIONAL, and the metric
// branches carry `widget.title`'s `I18nLabel` where `BaseSchema`
// declares a plain `string`. Both are pre-existing looseness in the
// widget types rather than anything this call site can state
// truthfully, so the narrowing is named here once (objectui#4548)
// instead of being spread across the two render sites below.
const componentSchema = getComponentSchema(widget) as BaseSchema | string | null | undefined;
const isSelfContained = widget.type === 'metric';
// `DashboardWidget.title` is the spec's `I18nLabel`: since
// 17.0.0-rc.6 an author may inline a per-locale map
Expand Down
11 changes: 9 additions & 2 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/

import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
import { SchemaRenderer, useActionEngine, useObjectLabel, PageVariablesProvider, usePageVariables } from '@object-ui/react';
import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
import type { ActionDef, ActionResult, ActionContext, ModalHandler, SduiDomPassThroughKey } from '@object-ui/core';
Expand Down Expand Up @@ -749,7 +749,14 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
? buildWidgetScopedFilter(widget, filterDefs, filterValues)
: undefined;
const componentSchema = (() => {
const cs = getComponentSchema() as Record<string, any>;
// `as BaseSchema`, not `as Record< string, any >` (objectui#4548):
// the old cast dropped the `type` every branch of
// `getComponentSchema` actually sets, so what reached
// `SchemaRenderer` was a bag with no component descriptor as far as
// the type system knew. Both spellings keep arbitrary key access
// (BaseSchema carries an index signature); only this one keeps
// `type`.
const cs = getComponentSchema() as BaseSchema;
if (scopedFilter && cs && FILTERABLE_COMPONENT_TYPES.has(cs.type)) {
return { ...cs, filter: mergeFilters(cs.filter, scopedFilter) };
}
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
LazyIcon,
} from '@object-ui/components';
import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff, Pencil } from 'lucide-react';
import { SchemaRenderer } from '@object-ui/react';
import { SchemaRenderer, toRenderableSchema } from '@object-ui/react';
import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields';
import type { DetailViewSection as DetailViewSectionType, DetailViewField, FieldMetadata } from '@object-ui/types';
import { applyDetailAutoLayout } from './autoLayout';
Expand Down Expand Up @@ -205,7 +205,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({

// If custom renderer provided
if (field.render) {
return <SchemaRenderer schema={field.render} data={{ ...data, value }} />;
return <SchemaRenderer schema={toRenderableSchema(field.render)} data={{ ...data, value }} />;
}

// Calculate responsive span class so col-span never exceeds the visible
Expand Down
6 changes: 3 additions & 3 deletions packages/plugin-detail/src/DetailTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import * as React from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent, Badge } from '@object-ui/components';
import { SchemaRenderer } from '@object-ui/react';
import { SchemaRenderer, toRenderableSchema } from '@object-ui/react';
import type { DetailViewTab } from '@object-ui/types';

export interface DetailTabsProps {
Expand Down Expand Up @@ -65,11 +65,11 @@ export const DetailTabs: React.FC<DetailTabsProps> = ({
{Array.isArray(tab.content) ? (
<div className="space-y-4">
{tab.content.map((schema, index) => (
<SchemaRenderer key={index} schema={schema} data={data} />
<SchemaRenderer key={index} schema={toRenderableSchema(schema)} data={data} />
))}
</div>
) : (
<SchemaRenderer schema={tab.content} data={data} />
<SchemaRenderer schema={toRenderableSchema(tab.content)} data={data} />
)}
</React.Suspense>
</TabsContent>
Expand Down
8 changes: 4 additions & 4 deletions packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { RecordComments } from './RecordComments';
import { ActivityTimeline } from './ActivityTimeline';
import { HistoryTimeline } from './HistoryTimeline';
import { RecordMetaFooter } from './RecordMetaFooter';
import { SchemaRenderer, useSafeFieldLabel, useDataInvalidation, useInlineEdit, useRowPredicate } from '@object-ui/react';
import { SchemaRenderer, toRenderableSchema, useSafeFieldLabel, useDataInvalidation, useInlineEdit, useRowPredicate } from '@object-ui/react';
import { buildExpandFields, getRecordDisplayName, formatTitleTemplate, userActionPredicates } from '@object-ui/core';
import { usePermissions } from '@object-ui/permissions';
import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n';
Expand Down Expand Up @@ -1157,7 +1157,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
menu sits at the far right edge — the standard placement
for "more options" affordances. */}
{headerActionNodes.map((action, index) => (
<SchemaRenderer key={`header-action-${index}`} schema={action} data={data} />
<SchemaRenderer key={`header-action-${index}`} schema={toRenderableSchema(action)} data={data} />
))}
</div>
</div>
Expand All @@ -1166,7 +1166,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
{/* Custom Header */}
{schema.header && (
<div>
<SchemaRenderer schema={schema.header} data={data} />
<SchemaRenderer schema={toRenderableSchema(schema.header)} data={data} />
</div>
)}

Expand Down Expand Up @@ -1696,7 +1696,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
{/* Custom Footer */}
{schema.footer && (
<div>
<SchemaRenderer schema={schema.footer} data={data} />
<SchemaRenderer schema={toRenderableSchema(schema.footer)} data={data} />
</div>
)}
</div>
Expand Down
19 changes: 15 additions & 4 deletions packages/plugin-report/src/ReportViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import React from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Badge, Skeleton } from '@object-ui/components';
import { SchemaRenderer } from '@object-ui/react';
import { SchemaRenderer, toRenderableSchema } from '@object-ui/react';
import type { ReportViewerSchema, ReportSection, ReportExportFormat, ReportField, ReportGroupBy } from '@object-ui/types';
import { Download, Printer, RefreshCw } from 'lucide-react';
import { exportReport } from './ReportExportEngine';
Expand Down Expand Up @@ -405,9 +405,20 @@ export const ReportViewer: React.FC<ReportViewerProps> = ({ schema, onRefresh })
<div className="border-t-2 border-dashed my-8 print:page-break-after-always" />
)}

{section.content && (
<SchemaRenderer schema={section.content} />
)}
{/* `content` may be a single node OR a list of them. The list
case used to be handed to `SchemaRenderer` whole
(objectui#4548): an array has no `type`, so the shallow copy
inside the renderer produced an index-keyed object and the
section rendered the red "Unknown component type: undefined"
box instead of its content. Arrays are mapped here rather
than widened into the renderer's declared input. */}
{Array.isArray(section.content)
? section.content.map((node, nodeIndex) => (
<SchemaRenderer key={nodeIndex} schema={toRenderableSchema(node)} />
))
: section.content && (
<SchemaRenderer schema={toRenderableSchema(section.content)} />
)}
</div>
);
})}
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-view/src/ViewSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
SelectValue,
} from '@object-ui/components';
import { cva } from 'class-variance-authority';
import { SchemaRenderer } from '@object-ui/react';
import { SchemaRenderer, toRenderableSchema } from '@object-ui/react';
import type { ViewSwitcherSchema, ViewType } from '@object-ui/types';
import {
Activity,
Expand Down Expand Up @@ -368,7 +368,7 @@ export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
);
}

return <SchemaRenderer schema={currentViewConfig.schema} {...props} />;
return <SchemaRenderer schema={toRenderableSchema(currentViewConfig.schema)} {...props} />;
})();

return (
Expand Down
Loading
Loading