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
17 changes: 17 additions & 0 deletions .changeset/forwardref-prop-erasure-4528.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/plugin-dashboard': minor
'@object-ui/plugin-list': minor
'@object-ui/app-shell': patch
---

`DashboardRenderer` and `ListView` serve the props they declare — the index signature stops erasing them

Both components declared a full props interface and neither was enforced. A `[key: string]: any` on `DashboardRendererProps` and `ListViewProps` puts `string` into `keyof Props`, so `'ref' extends keyof Props` is always true and React's `PropsWithoutRef` takes its `Omit` branch — and `Omit` over a type carrying a string index signature keeps only the index signature. Every declared property was dropped from the resolved type, on both sides: the render function received `{ [x: string]: any }` (so even `schema` was `any` inside the component), and every JSX call site was unchecked. Measured on the pre-fix source, `keyof ComponentProps<typeof DashboardRenderer>` was `string | number` and `ComponentProps<typeof DashboardRenderer>['onWidgetClick']` was `any`, while the interface went on declaring `(widgetId: string | null) => void`. `ListView` measured identically for `onRowClick`. This is objectui#4422 / PR #4438's trap in the two packages that issue left unswept.

Graded **minor, not major**: the interfaces have always DECLARED these props; the index signature erased them from the resolved type. Restoring what the interface documents is a FIX to the published contract, not a contract break — no documented capability is removed, and `any`-typed accidental passthrough was never the documented surface. Nothing in either package's README or docs endorses relying on it.

The props each component genuinely reads but never declared are now declared by name, at the type each one lands on: `dataSource` on both, plus `onAddRecord` / `onBulkAction` / `onPageSizeChange` / `onEdit` / `onDelete` / `onBulkDelete` on `ListView`. `DashboardRenderer`'s DOM pass-through keys are derived from `toDomProps`' whitelist constant itself, so the declaration and the runtime filter cannot drift — the "declare it and forward it by name" direction `@object-ui/core`'s `dom-props` doctrine asks for, rather than reopening the spread.

Type-only: the emitted JS for both packages is byte-identical before and after (verified by sha256 on `dist/index.js` and `dist/index.umd.cjs`), and both packages' runtime suites are untouched and green.

Three latent defects the erasure had been hiding are fixed with it, each surfaced by the repo-wide type-check: `DashboardWithConfig` typed its widget-select handler `(widgetId: string)` while `DashboardRenderer` calls `onWidgetClick(null)` to deselect; `InterfaceListPage` built a list schema whose `viewType` was a bare `string`; and `StudioDesignSurface` forwarded a `refreshKey` prop that no component in the chain declares or reads, so it was silently dropped. Per-package structural guards now pin the shape in both packages, covering the public `forwardRef` that takes its props whole — the spelling objectui#4438's `schema`-destructuring scan could not see.
9 changes: 8 additions & 1 deletion packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Empty, EmptyTitle, EmptyDescription, NavigationOverlay } from '@object-
import { Database } from 'lucide-react';
import { useObjectTranslation } from '@object-ui/i18n';
import { isSystemManagedField } from '@object-ui/types';
import type { ListViewSchema } from '@object-ui/types';
import { useMetadata } from '../providers/MetadataProvider';
import { useTenancyPosture } from '../hooks/useTenancyPosture';
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
Expand Down Expand Up @@ -372,7 +373,13 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
return {
type: 'list-view' as const,
objectName: objectDef.name,
viewType: (allowed[0] ?? view.type ?? 'grid'),
// Narrowed to the schema's declared union rather than left as `string`:
// `allowedVisualizations` arrives as `string[]`, so this expression is a
// bare `string` and only type-checked against `ListViewSchema` from
// objectui#4528 onwards — before that, `ListViewProps` carried a
// `[key: string]: any` that erased `schema` to `any` at this call site.
// The assertion changes no value; the runtime string is what it was.
viewType: (allowed[0] ?? view.type ?? 'grid') as ListViewSchema['viewType'],
columns,
...(filters.length ? { filter: filters } : {}),
...(sort?.length ? { sort } : {}),
Expand Down
17 changes: 15 additions & 2 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1885,10 +1885,24 @@ function renderStudioGridList(props: {
dataSource: unknown;
onEdit?: (record: Record<string, unknown>) => void;
className?: string;
/**
* Supplied by the plugin ObjectView's `renderListView` slot, and deliberately
* NOT read here. It used to be forwarded as `refreshKey={refreshKey}` to
* `ListView`, which reaches nothing: neither `ListView` nor any view
* component it renders declares or reads a `refreshKey` prop, so it rode the
* `{...props}` forward and was dropped. That was invisible until
* objectui#4528 stopped `ListViewProps` erasing itself.
*
* Removing the dead prop is behaviour-preserving; WIRING it is not, so it is
* deliberately left for triage rather than fixed under a type-only card. The
* working precedent is `app-shell/src/views/ObjectView.tsx`, which folds the
* slot's `refreshKey` into React's `key` to force a remount. Filed as a
* separate finding.
*/
refreshKey?: number;
onAddRecord?: () => void;
}): React.ReactElement {
const { schema: listSchema, dataSource: ds, onEdit, className, refreshKey, onAddRecord } = props;
const { schema: listSchema, dataSource: ds, onEdit, className, onAddRecord } = props;
return (
<ListView
schema={
Expand All @@ -1913,7 +1927,6 @@ function renderStudioGridList(props: {
onEdit={onEdit}
onAddRecord={onAddRecord}
className={className}
refreshKey={refreshKey}
/>
);
}
Expand Down
53 changes: 48 additions & 5 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import type { 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 } from '@object-ui/core';
import type { ActionDef, ActionResult, ActionContext, ModalHandler, SduiDomPassThroughKey } from '@object-ui/core';
import {
resolveDashboardFilterDefs,
dashboardFilterVariableDefs,
Expand All @@ -19,6 +19,7 @@ import {
} from '@object-ui/core';
import { cn, Card, CardHeader, CardTitle, CardContent, Button, getLazyIcon } from '@object-ui/components';
import { forwardRef, useState, useEffect, useCallback, useMemo, useRef, Fragment } from 'react';
import type { HTMLAttributes } from 'react';
import { RefreshCw } from 'lucide-react';
import {
DndContext,
Expand Down Expand Up @@ -153,9 +154,52 @@ const LEGACY_RETIRED_WIDGET_SCHEMA = {
className: 'flex h-full w-full items-center justify-center rounded border border-dashed border-destructive/40 bg-destructive/5 p-4 text-center text-destructive',
} as const;

export interface DashboardRendererProps {
/**
* The dashboard renderer's props.
*
* ## Why there is no `[key: string]: any` here (objectui#4528)
*
* There used to be one, and it erased this entire interface. A string index
* signature puts `string` into `keyof Props`, so `'ref' extends keyof Props` is
* always true and React's `PropsWithoutRef` takes its `Omit` branch — and
* `Omit` over a type carrying a string index signature keeps ONLY the index
* signature. Every declared property above was dropped from the resolved type:
* `keyof React.ComponentProps< typeof DashboardRenderer >` measured as
* `string | number`, and `onWidgetClick` measured as `any` at every JSX call
* site, while this interface went on declaring
* `(widgetId: string | null) => void`. The declaration was right and no
* consumer was held to it — a prop typo or a wrong-arity handler type-checked.
*
* The same trap, in `packages/components`, is objectui#4422 / PR #4438; this is
* the sweep of the two packages that issue left unswept.
*
* ## How the DOM pass-through survives without it
*
* `SchemaRenderer` hands this component the authored node's own keys, so the
* render function still collects an open rest object — but what may reach the
* element is decided by `toDomProps`' WHITELIST (objectui#4432), not by this
* type. The keys that whitelist forwards are therefore declared here BY NAME,
* derived from the whitelist constant itself so the two cannot drift, which is
* exactly what `@object-ui/core`'s `dom-props` doctrine asks for: "Deliberate
* DOM pass-through beyond this set stays available the objectui#4435 way —
* DECLARE it and forward it by name. Do not reopen the spread."
*/
export interface DashboardRendererProps
extends Pick<HTMLAttributes<HTMLDivElement>, SduiDomPassThroughKey> {
schema: DashboardComponentSchema;
className?: string;
/**
* Data-source adapter for the widgets this dashboard renders.
*
* Destructured by the render function and handed to `DatasetWidget` /
* `SchemaRenderer`, and passed by both in-repo hosts (`DashboardView`,
* `DashboardPreview`) — but never declared until objectui#4528, because the
* index signature above was answering for it. Typed `any` deliberately: that
* is precisely what it resolved to before, so declaring it changes what is
* DECLARED without changing what any call site is held to. Narrowing it to a
* real adapter type is a separate change with its own consumer sweep.
*/
dataSource?: any;
/** Callback invoked when dashboard refresh is triggered (manual or auto) */
onRefresh?: () => void;
/** Total record count to display */
Expand Down Expand Up @@ -186,11 +230,10 @@ export interface DashboardRendererProps {
* title/subtitle so we don't display them twice.
*/
hideHeaderText?: boolean;
[key: string]: any;
}

const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps>(
({ schema, className, dataSource, onRefresh, recordCount, userActions, designMode, selectedWidgetId, onWidgetClick, onWidgetsReorder, modalHandler, scriptHandlers, hideHeaderText, ...props }, ref) => {
({ schema, className, dataSource, onRefresh, recordCount, userActions, designMode, selectedWidgetId, onWidgetClick, onWidgetsReorder, modalHandler, scriptHandlers, hideHeaderText, ...props }: DashboardRendererProps & { [key: string]: any }, ref) => {
// Auto-infer the grid column count when the dashboard schema doesn't
// specify one. Spec convention is a 12-column grid (widgets use w: 3 for
// quarter-row KPIs, w: 6 for half-row charts, etc.). If we always default
Expand Down Expand Up @@ -426,7 +469,7 @@ const DashboardRendererInner = forwardRef<HTMLDivElement, DashboardRendererProps
* then declines to optimize the component around. It lands on a plain
* element with no memoized child, so identity churn costs nothing.
*/
const handleHostClick = (e: React.MouseEvent) => {
const handleHostClick = (e: React.MouseEvent<HTMLDivElement>) => {
handleBackgroundClick(e);
if (typeof props.onClick === 'function') props.onClick(e);
};
Expand Down
11 changes: 10 additions & 1 deletion packages/plugin-dashboard/src/DashboardWithConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,17 @@ export function DashboardWithConfig({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedWidgetId, configVersion]);

// `string | null`, not `string`: `DashboardRenderer` calls `onWidgetClick(null)`
// to DESELECT when a design-mode click lands on the dashboard background, and
// this handler has always received that `null` at runtime — `selectedWidgetId`
// is a `useState< string | null >` precisely so it can hold it. The narrower
// `(widgetId: string)` type-checked only because a `[key: string]: any` on
// `DashboardRendererProps` erased every declared prop from the resolved type,
// so this call site was never held to the declared
// `(widgetId: string | null) => void` (objectui#4528). Widening the annotation
// is what the contract always said; nothing about the behaviour changes.
const handleWidgetSelect = useCallback(
(widgetId: string) => {
(widgetId: string | null) => {
setSelectedWidgetId(widgetId);
setConfigOpen(true);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,14 +237,22 @@ describe("the grid's click channel has one carrier (objectui#4432)", () => {
<DashboardRenderer
schema={DASHBOARD}
designMode
// `id` is annotated rather than inferred, and that is not a style
// choice: `DashboardRendererProps` carries `[key: string]: any`, which
// makes `'ref' extends keyof Props` true, so React's `PropsWithoutRef`
// resolves to `Pick<Props, string | number>` and every NAMED prop —
// `onWidgetClick` included — collapses to the index signature at the
// JSX call site. The annotation restates the type the interface itself
// still declares, `(widgetId: string | null) => void`, so this callback
// is checked even though the element around it is not (objectui#4040).
// `id` is annotated rather than inferred. This used to be load-bearing:
// `DashboardRendererProps` carried a `[key: string]: any`, which made
// `'ref' extends keyof Props` true, so React's `PropsWithoutRef`
// resolved to `Pick<Props, string | number>` and every NAMED prop —
// `onWidgetClick` included — collapsed to the index signature at the
// JSX call site. The annotation restated the type the interface itself
// still declared, so this callback was checked even though the element
// around it was not (objectui#4040).
//
// objectui#4528 removed that index signature, so the annotation is now
// REDUNDANT rather than load-bearing — `onWidgetClick` resolves to the
// declared `(widgetId: string | null) => void` on its own, and
// `DashboardRenderer.propsResolution.test.ts` pins exactly that. It is
// kept because restating a declared type costs nothing and this call
// site reads more clearly with the contract spelled out; deleting it
// would also be correct.
onWidgetClick={(id: string | null) => selections.push(id)}
onClick={() => hostClicks.push('host')}
/>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* 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.
*/

/**
* objectui#4528 — COMPILE-TIME pins on the props a `DashboardRenderer` JSX call
* site is actually held to.
*
* These assertions are erased at runtime; `tsc` is the only thing that can
* check them, which is why this file is carried by
* `packages/plugin-dashboard/tsconfig.test.json` and why the `expect`s below
* are deliberately trivial — the real assertions are the `Assert< Equal< … > >`
* types, and a violation is a compile error, not a red test.
*
* ## What was measured before the fix
*
* `DashboardRendererProps` carried a `[key: string]: any`, which puts `string`
* into `keyof Props`, so React's `PropsWithoutRef` took its `Omit` branch and
* `Omit` over a string index signature keeps ONLY the index signature. On the
* pre-fix source, compiled through this same project:
*
* keyof React.ComponentProps< typeof DashboardRenderer > -> string | number
* React.ComponentProps< typeof DashboardRenderer >['onWidgetClick'] -> any
* DashboardRendererProps['onWidgetClick'] -> ((widgetId: string | null) => void) | undefined
*
* i.e. the interface declared the contract and no consumer was held to it. The
* pins below are exactly those three reads, in their fixed direction.
*/

import { describe, it, expect } from 'vitest';
import type { ComponentProps } from 'react';
import { DashboardRenderer, type DashboardRendererProps } from '../DashboardRenderer';

type Assert<T extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type IsAny<T> = 0 extends 1 & T ? true : false;

/** The props a JSX call site is held to. */
type CallSiteProps = ComponentProps<typeof DashboardRenderer>;

// 1. The declared callback survives to the call site with its REAL signature.
// Before the fix this was `any`, so a wrong-arity or wrong-typed handler —
// and any prop typo next to it — passed silently.
type _OnWidgetClickIsDeclared = Assert<
Equal<CallSiteProps['onWidgetClick'], ((widgetId: string | null) => void) | undefined>
>;

// 2. …and that is not vacuously true because the whole thing is `any`.
type _OnWidgetClickIsNotAny = Assert<Equal<IsAny<CallSiteProps['onWidgetClick']>, false>>;

// 3. The call-site type and the DECLARED interface agree key for key, once the
// `ref` / `key` that `RefAttributes` contributes are set aside.
type _DeclaredKeysAgree = Assert<
Equal<Exclude<keyof CallSiteProps, 'ref' | 'key'>, keyof DashboardRendererProps>
>;

// 4. `keyof` is a union of literal keys, NOT the erased `string | number`. THIS
// is the assertion that discriminates: on the pre-fix shape
// `keyof CallSiteProps` was `string | number`, so `string` extended it and
// this pin was `true` — measured, and it is the whole defect in one line.
// (Note assertion 3 alone would NOT have caught it: pre-fix BOTH sides were
// erased to `string | number`, so they agreed with each other while agreeing
// with nothing the interface declared.)
type _KeysAreNotWidened = Assert<Equal<string extends keyof CallSiteProps ? true : false, false>>;

// 5. A named prop the interface declares is reachable and correctly typed.
type _SchemaSurvives = Assert<Equal<CallSiteProps['schema'], DashboardRendererProps['schema']>>;
type _DesignModeSurvives = Assert<Equal<CallSiteProps['designMode'], boolean | undefined>>;

// 6. The DOM pass-through keys the `toDomProps` whitelist forwards are DECLARED
// rather than reachable through an index signature (objectui#4432 + #4528).
type _OnClickIsDeclared = Assert<Equal<IsAny<CallSiteProps['onClick']>, false>>;

// 7. `dataSource` is declared — the render function destructures it and both
// in-repo hosts pass it, but it was only ever reachable through the index
// signature. `'dataSource' extends keyof …` is false the moment it is not.
type _DataSourceIsDeclared = Assert<'dataSource' extends keyof DashboardRendererProps ? true : false>;

describe('objectui#4528 — DashboardRenderer serves its declared props', () => {
it('pins the resolved call-site props at compile time', () => {
// The assertions are the types above; this body only keeps the file a test.
const probe: CallSiteProps['onWidgetClick'] = (widgetId: string | null) => {
void widgetId;
};
expect(typeof probe).toBe('function');
});
});
Loading
Loading