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
31 changes: 31 additions & 0 deletions .changeset/25918-renderer-props-unknown-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@object-ui/types': patch
'@object-ui/components': patch
---

fix(types,components)!: a renderer's props index signature is `unknown`, not `any`, so a forwarded key must be narrowed before it is used

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (type-surface-only ui/packages/types/src/base.ts#ComponentRendererProps) A TypeScript annotation narrowing on a published renderer-props type: no metadata key, accepted value, export or error code is removed or renamed, nothing at runtime changes, and `objectstack migrate meta` has nothing to rewrite. The affected party is a TypeScript consumer, reached by its own compiler. -->

**BREAKING** type-surface narrowing, compile-time only. Nothing a renderer receives or renders at runtime changes.

- **`@object-ui/types`.** `ComponentRendererProps` keeps its index signature, so every key the host forwards is still admitted, but the signature now maps to `unknown` instead of `any`.
- **`@object-ui/components`.** The built-in renderers' own inline props annotations move the same way. The three host keys renderers read by name and pass to their root element (`data-obj-id`, `data-obj-type` and `style`, written or forwarded by `SchemaRenderer`) are declared once in a package-local type instead. The host-supplied callbacks and values a renderer reads by name (`onPageChange` on `pagination`, `onChange` / `value` on `input-otp`, `loading` on `button`, `className` on `text`, react-hook-form's `ref` on the form's built-in controls) are declared on that renderer's props.

**Migration.** A renderer typed with `ComponentRendererProps` that reads a forwarded key:

```ts
// FROM — compiled, because the key was `any`
const Pager = ({ schema, ...props }: ComponentRendererProps<PagerSchema>) =>
<Nav onSelect={(n) => props.onPageChange(n)} />;

// TO — declare the key you read on your own props, or narrow it before use
type PagerProps = ComponentRendererProps<PagerSchema> & { onPageChange?: (page: number) => void };
const Pager = ({ schema, onPageChange }: PagerProps) =>
<Nav onSelect={(n) => onPageChange?.(n)} />;
```

What to do: nothing if your renderers never read a forwarded key off `ComponentRendererProps` without declaring it. Otherwise declare the key on your renderer's props (as above) or narrow the `unknown` value (`typeof x === 'function'`, `typeof x === 'string'`) before using it.
31 changes: 31 additions & 0 deletions ui/packages/components/src/lib/renderer-host-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* 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.
*/

import type { CSSProperties } from 'react';

/**
* The host keys a renderer in this package reads BY NAME off its props and
* hands to its root element (#25918).
*
* A renderer's props carry an index signature to `unknown`, not `any`: a key
* the host forwards is still admitted, but the renderer has to narrow it
* before it can use it. These three are the exception that is declared rather
* than narrowed at every site, because they are not authoring keys a renderer
* interprets — `SchemaRenderer` (`@object-ui/react`) writes `data-obj-id` from
* the node's `id` and `data-obj-type` from its `type`, and forwards the node's
* `style` (`BaseSchema.style`) — and every renderer that reads them passes
* them straight to the DOM.
*
* Intersect it into a renderer's props annotation; ⛔ never widen the index
* signature back to `any` to make a by-name read compile.
*/
export interface RendererHostProps {
'data-obj-id'?: string;
'data-obj-type'?: string;
style?: CSSProperties;
}
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/action/action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { cn } from '../../lib/utils';
import { useIsMobile } from '../../hooks/use-mobile';
import { ButtonGroup } from '../../custom/button-group';
import { withEvaluatedProperties } from './static-params';
import type { RendererHostProps } from '../../lib/renderer-host-props';

function useActionsLabel(): string {
// useObjectTranslation is provider-safe (never throws); no try/catch, which
Expand Down Expand Up @@ -94,7 +95,7 @@ function useActionsLabel(): string {
// exported and never appears in JSX, and `Registry.register` takes
// `ComponentRenderer<T = any> = T`.
const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema; className?: string }>(
({ schema, className, ...props }: { schema: ActionBarSchema; className?: string; [key: string]: any }, ref) => {
({ schema, className, ...props }: { schema: ActionBarSchema; className?: string; [key: string]: unknown } & RendererHostProps, ref) => {
const actionsAriaLabel = useActionsLabel();
const {
'data-obj-id': dataObjId,
Expand Down
5 changes: 3 additions & 2 deletions ui/packages/components/src/renderers/action/action-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { hasDeclaredVisibilityGate } from './visibility-gate';
import { useAutoTriggerOnce } from './auto-trigger';
import { readStaticParamValues } from './static-params';
import { DisabledReasonTrigger, describedByWithReason, useDisabledReason } from './disabled-reason';
import type { RendererHostProps } from '../../lib/renderer-host-props';

/** A record value: a plain object, never `null` or an array. */
function asRecord(value: unknown): Record<string, unknown> | undefined {
Expand Down Expand Up @@ -97,7 +98,7 @@ function withUndoBaseline(
// `ActionButtonProps` as the block's AUTHORED property bag (`actionType`, `label`, `variant`, …).
// This is the React envelope that carries a node (`schema`, `context`, `disabled`, an open
// tail) — a different layer, named as objectui#7265 named `RecordAlertRendererProps`.
export interface ActionButtonRendererProps {
export interface ActionButtonRendererProps extends RendererHostProps {
schema: UIActionSchema & { type: string; className?: string; actionType?: string };
className?: string;
/** Override context for this specific action */
Expand All @@ -110,7 +111,7 @@ export interface ActionButtonRendererProps {
* therefore never reaches the DOM spread — see the destructure.
*/
disabled?: boolean;
[key: string]: any;
[key: string]: unknown;
}

// `PropsWithoutRef` would collapse `ActionButtonRendererProps` to its bare index
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/action/action-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
menuItemReasonAria,
useDisabledReason,
} from './disabled-reason';
import type { RendererHostProps } from '../../lib/renderer-host-props';

// No group-level `name` (objectui#11168): nothing renders, forwards or keys on
// it, and `@objectstack/spec` refuses it on this block, so the registration
Expand Down Expand Up @@ -275,7 +276,7 @@ DropdownActionItem.displayName = 'DropdownActionItem';
// host-EVALUATED enablement verdict, declared rather than left to the index
// signature, as `ActionButtonRendererProps` declares it (objectui#9131).
const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSchema; className?: string }>(
({ schema, className, ...props }: { schema: ActionGroupSchema; className?: string; disabled?: boolean; [key: string]: any }, ref) => {
({ schema, className, ...props }: { schema: ActionGroupSchema; className?: string; disabled?: boolean; [key: string]: unknown } & RendererHostProps, ref) => {
const {
'data-obj-id': dataObjId,
'data-obj-type': dataObjType,
Expand Down
5 changes: 3 additions & 2 deletions ui/packages/components/src/renderers/action/action-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { hasDeclaredVisibilityGate } from './visibility-gate';
import { useAutoTriggerOnce } from './auto-trigger';
import { readStaticParamValues } from './static-params';
import { DisabledReasonTrigger, describedByWithReason, useDisabledReason } from './disabled-reason';
import type { RendererHostProps } from '../../lib/renderer-host-props';

/**
* The declared props. `schema` is `UIActionSchema` (objectui#4418) for the same
Expand All @@ -44,7 +45,7 @@ import { DisabledReasonTrigger, describedByWithReason, useDisabledReason } from
// `ActionIconProps` as the block's AUTHORED property bag (`actionType`, `label`, `variant`, …).
// This is the React envelope that carries a node (`schema`, `context`, `disabled`, an open
// tail) — a different layer, named as objectui#7265 named `RecordAlertRendererProps`.
export interface ActionIconRendererProps {
export interface ActionIconRendererProps extends RendererHostProps {
schema: UIActionSchema & { type: string; className?: string; actionType?: string };
className?: string;
context?: Record<string, any>;
Expand All @@ -54,7 +55,7 @@ export interface ActionIconRendererProps {
* signature, consumed by name below, never re-spread onto the DOM.
*/
disabled?: boolean;
[key: string]: any;
[key: string]: unknown;
}

// Index signature on the parameter annotation, not on the `forwardRef` type
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/action/action-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { hasDeclaredVisibilityGate } from './visibility-gate';
import { useAutoTriggerOnce } from './auto-trigger';
import { readActionEntryParamValues } from './static-params';
import { DisabledReasonMenuLabel, menuItemReasonAria, useDisabledReason } from './disabled-reason';
import type { RendererHostProps } from '../../lib/renderer-host-props';

function useMoreActionsLabel(): string {
// useObjectTranslation is provider-safe (never throws); no try/catch, which
Expand Down Expand Up @@ -207,7 +208,7 @@ ActionAutoTrigger.displayName = 'ActionAutoTrigger';
// host-EVALUATED enablement verdict, declared rather than left to the index
// signature, as `ActionButtonRendererProps` declares it (objectui#9131).
const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSchema; className?: string }>(
({ schema, className, ...props }: { schema: ActionMenuSchema; className?: string; disabled?: boolean; [key: string]: any }, ref) => {
({ schema, className, ...props }: { schema: ActionMenuSchema; className?: string; disabled?: boolean; [key: string]: unknown } & RendererHostProps, ref) => {
const {
'data-obj-id': dataObjId,
'data-obj-type': dataObjType,
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/basic/button-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import { ComponentRegistry } from '@object-ui/core';
import type { ButtonGroupSchema } from '@object-ui/types';
import { Button } from '../../ui';
import { cn } from '../../lib/utils';
import type { RendererHostProps } from '../../lib/renderer-host-props';

ComponentRegistry.register('button-group',
({ schema, ...props }: { schema: ButtonGroupSchema; [key: string]: any }) => {
({ schema, ...props }: { schema: ButtonGroupSchema; [key: string]: unknown } & RendererHostProps) => {
const {
'data-obj-id': dataObjId,
'data-obj-type': dataObjType,
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/basic/div.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ComponentRegistry } from '@object-ui/core';
import type { DivSchema } from '@object-ui/types';
import { renderChildren } from '../../lib/utils';
import { forwardRef } from 'react';
import type { RendererHostProps } from '../../lib/renderer-host-props';

/**
* Deprecated types already reported in this module instance — the notice is a
Expand Down Expand Up @@ -128,7 +129,7 @@ const DIV_DEPRECATION_NOTICE =
// argument — mechanism note on `action:bar` (objectui#4422), pinned by
// `__tests__/forwardref-props-annotation.guard.test.ts`.
const DivRenderer = forwardRef<HTMLDivElement, { schema: DivSchema; className?: string }>(
({ schema, className, ...props }: { schema: DivSchema; className?: string; [key: string]: any }, ref) => {
({ schema, className, ...props }: { schema: DivSchema; className?: string; [key: string]: unknown } & RendererHostProps, ref) => {
// Deprecation notice, once per module load (objectui#3965, see
// warnDeprecatedOnce). There is no provenance exemption any more: the
// declaration below names both authoring surfaces (objectui#10757), so a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { renderChildren } from '../../lib/utils';
import { createElement, forwardRef, useContext } from 'react';
import type { MouseEventHandler } from 'react';

type AnyProps = { schema: any; className?: string; [key: string]: any };
type AnyProps = { schema: any; className?: string; [key: string]: unknown };

// Tags that take no children.
const VOID_TAGS = new Set(['img', 'hr', 'br']);
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/basic/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SquareDashed } from 'lucide-react';
import React, { forwardRef } from 'react';
import { cn } from '../../lib/utils';
import { describeIconLookup, resolveIcon } from '../action/resolve-icon';
import type { RendererHostProps } from '../../lib/renderer-host-props';

/**
* The glyph rendered when the requested one does not resolve (objectui#5631).
Expand Down Expand Up @@ -108,7 +109,7 @@ import { describeIconLookup, resolveIcon } from '../action/resolve-icon';
// argument — mechanism note on `action:bar` (objectui#4422), pinned by
// `__tests__/forwardref-props-annotation.guard.test.ts`.
const IconRenderer = forwardRef<SVGSVGElement, { schema: IconSchema; className?: string }>(
({ schema, className, ...props }: { schema: IconSchema; className?: string; [key: string]: any }, ref) => {
({ schema, className, ...props }: { schema: IconSchema; className?: string; [key: string]: unknown } & RendererHostProps, ref) => {
// Extract designer-related props
const {
'data-obj-id': dataObjId,
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/basic/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@

import { ComponentRegistry } from '@object-ui/core';
import type { ImageSchema } from '@object-ui/types';
import type { RendererHostProps } from '../../lib/renderer-host-props';

ComponentRegistry.register('image',
({ schema, className, ...props }: { schema: ImageSchema; className?: string; [key: string]: any }) => {
({ schema, className, ...props }: { schema: ImageSchema; className?: string; [key: string]: unknown } & RendererHostProps) => {
// Extract designer-related props
const {
'data-obj-id': dataObjId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import { ComponentRegistry } from '@object-ui/core';
import type { NavigationMenuSchema } from '@object-ui/types';
import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink } from '../../ui/navigation-menu';
import { cn } from '../../lib/utils';
import type { RendererHostProps } from '../../lib/renderer-host-props';

ComponentRegistry.register('navigation-menu',
({ schema, ...props }: { schema: NavigationMenuSchema; [key: string]: any }) => {
({ schema, ...props }: { schema: NavigationMenuSchema; [key: string]: unknown } & RendererHostProps) => {
const {
'data-obj-id': dataObjId,
'data-obj-type': dataObjType,
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/basic/pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { PaginationSchema } from '@object-ui/types';
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '../../ui/pagination';
import { createSafeTranslation } from '@object-ui/i18n';
import React from 'react';
import type { RendererHostProps } from '../../lib/renderer-host-props';

/**
* objectui#12145 — the previous / next links' accessible names. The Shadcn
Expand All @@ -29,7 +30,7 @@ const usePaginationTranslation = createSafeTranslation(
);

ComponentRegistry.register('pagination',
({ schema, ...props }: { schema: PaginationSchema; [key: string]: any }) => {
({ schema, ...props }: { schema: PaginationSchema; onPageChange?: (page: number) => void; [key: string]: unknown } & RendererHostProps) => {
const { t } = usePaginationTranslation();
const {
'data-obj-id': dataObjId,
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/basic/separator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { ComponentRegistry } from '@object-ui/core';
import type { SeparatorSchema } from '@object-ui/types';
import { Separator } from '../../ui';
import { forwardRef } from 'react';
import type { RendererHostProps } from '../../lib/renderer-host-props';

// Index signature on the parameter annotation, not on the `forwardRef` type
// argument — mechanism note on `action:bar` (objectui#4422), pinned by
// `__tests__/forwardref-props-annotation.guard.test.ts`.
const SeparatorRenderer = forwardRef<HTMLDivElement, { schema: SeparatorSchema; className?: string }>(
({ schema, className, ...props }: { schema: SeparatorSchema; className?: string; [key: string]: any }, ref) => {
({ schema, className, ...props }: { schema: SeparatorSchema; className?: string; [key: string]: unknown } & RendererHostProps, ref) => {
// Extract designer-related props
const {
'data-obj-id': dataObjId,
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/basic/span.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { isHtmlTierNode } from '@objectstack/sdui-parser';
import type { TextSpanSchema } from '@object-ui/types';
import { renderChildren } from '../../lib/utils';
import { forwardRef } from 'react';
import type { RendererHostProps } from '../../lib/renderer-host-props';

/**
* Deprecated types already reported in this module instance — the notice is a
Expand Down Expand Up @@ -67,7 +68,7 @@ const SPAN_DEPRECATION_NOTICE =
// argument — mechanism note on `action:bar` (objectui#4422), pinned by
// `__tests__/forwardref-props-annotation.guard.test.ts`.
const SpanRenderer = forwardRef<HTMLSpanElement, { schema: TextSpanSchema; className?: string }>(
({ schema, className, ...props }: { schema: TextSpanSchema; className?: string; [key: string]: any }, ref) => {
({ schema, className, ...props }: { schema: TextSpanSchema; className?: string; [key: string]: unknown } & RendererHostProps, ref) => {
// Deprecation notice — JSON-authored nodes only (objectui#4000), once per
// module load (objectui#3965, see warnDeprecatedOnce). Both rulings landed
// for `div` first; objectui#4917 brings this renderer level with it.
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/components/src/renderers/basic/text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { ComponentRegistry, toDomProps } from '@object-ui/core';
import type { TextSchema } from '@object-ui/types';
import { cn } from '../../lib/utils';
import type { RendererHostProps } from '../../lib/renderer-host-props';

type TextVariant = NonNullable<TextSchema['variant']>;
type TextAlign = NonNullable<TextSchema['align']>;
Expand Down Expand Up @@ -110,7 +111,7 @@ const ALIGN_CLASS: Record<TextAlign, string> = {
};

ComponentRegistry.register('text',
({ schema, ...props }: { schema: TextSchema; [key: string]: any }) => {
({ schema, ...props }: { schema: TextSchema; className?: string; [key: string]: unknown } & RendererHostProps) => {
// Text is a special case as it might be rendered as a fragment or span depending on usage.
// However, to support drag and drop in designer, it MUST be wrapped in an element if props are passed.

Expand Down
2 changes: 1 addition & 1 deletion ui/packages/components/src/renderers/complex/carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { renderChildren } from '../../lib/utils';

ComponentRegistry.register('carousel',
({ schema, className, ...props }: { schema: CarouselSchema; className?: string; [key: string]: any }) => (
({ schema, className, ...props }: { schema: CarouselSchema; className?: string; [key: string]: unknown }) => (
<Carousel
opts={schema.opts}
orientation={schema.orientation || 'horizontal'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import { useDisplayLocale } from '@object-ui/i18n';
// the spec's INLINE locale map — see `BaseSchema.label` (objectui#4580).
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
import { FilterBuilder } from '../../custom/filter-builder';
import type { RendererHostProps } from '../../lib/renderer-host-props';

ComponentRegistry.register('filter-builder',
// `value` is declared because it is read by name: a host that binds the node
// (a form field) forwards the group it holds, the same authored `FilterGroup`
// the node's own `value` is.
({ schema, className, onChange, ...props }: { schema: FilterBuilderSchema; className?: string; onChange?: (event: any) => void; value?: FilterGroup; [key: string]: any }) => {
({ schema, className, onChange, ...props }: { schema: FilterBuilderSchema; className?: string; onChange?: (event: any) => void; value?: FilterGroup; [key: string]: unknown } & RendererHostProps) => {
// Read-time resolution against the display locale (objectui#4580 revised
// Q1-A). `BaseSchema.label` accepts `string | I18nLabel`; rendering the map
// straight into a text node THREW "Objects are not valid as a React child".
Expand Down
2 changes: 1 addition & 1 deletion ui/packages/components/src/renderers/complex/resizable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { renderChildren } from '../../lib/utils';

ComponentRegistry.register('resizable',
({ schema, className, ...props }: { schema: ResizableSchema; className?: string; [key: string]: any }) => {
({ schema, className, ...props }: { schema: ResizableSchema; className?: string; [key: string]: unknown }) => {
const panels = Array.isArray(schema.panels) ? schema.panels : [];
return (
<ResizablePanelGroup
Expand Down
Loading
Loading