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/components-no-implicit-any-4353.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/components': patch
---

`@object-ui/components` compiles under `noImplicitAny` — the workspace's last strict-relaxing package

`packages/components/tsconfig.json` carried `"noImplicitAny": false`, the only place in the workspace that relaxed a `strict` sub-flag, under a comment that explained the neighbouring `rootDir` removal rather than the flag itself. `tsconfig.test.json` mirrored the one flag deliberately, so that a test project could not become the compiler of record for a source strictness decision the build config owns. Both now simply inherit `strict: true` from the root config, and the mirror's reasoning is rewritten to record why the mirror is gone rather than deleted silently.

Turning the flag on reported 26 implicitly-`any` sites in five renderer source files and 2 in the package's own tests, all of which now have real types. Nothing about the runtime changed; every one of the package's 1077 tests passes untouched.

Two of those signatures were typed by measurement rather than by preference, and both are worth recording:

The ten `sidebar.tsx` entry points follow the convention the package's other registered renderers already use — an inline `{ schema: <X>Schema; [key: string]: any }` annotation naming the registered component's own schema type (21 occurrences across the renderer tree, against zero uses of `ComponentRendererProps`). Only `'sidebar'` itself has a schema type in the registry map; the other ten registrations are sidebar *parts* with none of their own, so they take `BaseSchema`, the type every registered node satisfies. Annotating them `SidebarSchema` would have asserted `type: 'sidebar'` on a node whose type is `'sidebar-header'`.

The action renderers' callbacks are typed from `UIActionSchema`, not the legacy `ActionSchema` those three files import for their declarations. The legacy interface (`crud.ts`, already `@deprecated`) has no `locations`, so the shared `actionRendersAt` placement predicate rejects it outright; its `variant` union has no `'primary'`, the value the objectui#2339 ordering tie-break compares against; and its `type` is the literal `'action'`, while the actions actually flowing through these renderers carry `'form' | 'script' | 'url' | 'flow' | 'api' | 'modal'`. `action:bar`'s own documented example is a `UIActionSchema`. None of this was checkable before, because the props type never reached the callbacks at all: `forwardRef` routes props through `PropsWithoutRef`, whose `Omit` collapses a props type carrying `[key: string]: any` down to the bare index signature, so `schema` arrived as `any` and every callback under it inferred `any` too. The fix annotates each action list once where it enters and lets the `filter`/`some`/`map` chains below infer.

Graded `patch`: no declaration this package publishes changes shape. The three action schema interfaces and the leaf components whose props moved to `UIActionSchema` are internal — none is re-exported from `src/index.ts`. The `actions?: ActionSchema[]` keys those interfaces still declare remain on the legacy type; reconciling that declaration with the type the implementation actually receives reaches roughly 46 sites across 12 files and is filed separately.
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ function renderDiv(schema: Record<string, unknown>) {
}

function deprecationCalls(spy: ReturnType<typeof vi.spyOn>): unknown[][] {
return spy.mock.calls.filter((args) => DEPRECATION_RE.test(String(args[0])));
// `ReturnType<typeof vi.spyOn>` erases the spied signature, so `mock.calls`
// arrives as `any` and this parameter had no type to infer. `unknown[]` is
// the row type this function already DECLARES it returns (objectui#4353).
return spy.mock.calls.filter((args: unknown[]) => DEPRECATION_RE.test(String(args[0])));
}

describe('div deprecation notice — once per module load (#3965)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,11 @@ describe('page:header — legacy dialect still falls back (#3521)', () => {
return () => warn.mockRestore();
});

// `ReturnType<typeof vi.spyOn>` erases the spied signature, so `mock.calls`
// arrives as `any` and this parameter had no type to infer. A console.warn
// call is a list of arguments, of which only the first is read (objectui#4353).
const legacyWarnings = () =>
warn.mock.calls.filter(c => String(c[0]).includes('legacy expression dialect'));
warn.mock.calls.filter((c: unknown[]) => String(c[0]).includes('legacy expression dialect'));

it('evaluates a `${…}` template predicate on the legacy engine', () => {
renderHeader({ name: 'zoo_legacy_template', visible: '${record.f_status === "open"}' });
Expand Down
33 changes: 27 additions & 6 deletions packages/components/src/renderers/action/action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@

import React, { forwardRef, useMemo } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { ActionSchema, ActionLocation, ActionComponent } from '@object-ui/types';
import type { ActionSchema, UIActionSchema, ActionLocation, ActionComponent } from '@object-ui/types';
import { ACTION_LOCATIONS, actionRendersAt } from '@object-ui/types';
import { useCondition, toPredicateInput, useCapabilityGate } from '@object-ui/react';
import { useObjectTranslation } from '@object-ui/i18n';
Expand Down Expand Up @@ -129,7 +129,27 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;

// Filter business actions by location and deduplicate by name
const filteredActions = useMemo(() => {
const actions = schema.actions || [];
// Annotated, not inferred, and `UIActionSchema` rather than the legacy
// `ActionSchema` this file imports for its declarations. Two facts, both
// measured in objectui#4353:
//
// 1. The declaration does not survive into `schema`. `forwardRef` routes
// props through `PropsWithoutRef`, whose `Omit` collapses a props type
// carrying `[key: string]: any` down to the bare index signature — so
// `schema` arrives as `any` and every callback below it inferred
// `any` too. One annotation at the point the list ENTERS types the
// whole `filter`/`some`/`map` chain by inference.
// 2. `UIActionSchema` is what actually flows in. The legacy
// `ActionSchema` (`crud.ts`, `@deprecated`) has no `locations`, so the
// shared `actionRendersAt` predicate rejects it outright, and its
// `variant` union has no `'primary'` — the value the objectui#2339
// tie-break below compares against. This file's own doc example is a
// `UIActionSchema` (`type: 'script'`; legacy requires `type: 'action'`).
//
// The exported `ActionBarSchema.actions` key still DECLARES the legacy
// type — that mismatch predates this change, is filed separately, and is
// deliberately not migrated here (it reaches ~46 sites across 12 files).
const actions: UIActionSchema[] = schema.actions || [];
// [ADR-0066 D4 / framework#3923] Capability gate — this bar filters its
// own set instead of going through `ActionEngine.getActionsForLocation`,
// so without this a `list_toolbar` action declaring a capability nobody
Expand Down Expand Up @@ -183,7 +203,8 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
// System actions: always go into the overflow menu, deduped by name,
// never filtered by location (they're chrome, not business logic).
const systemActions = useMemo(() => {
const actions = schema.systemActions || [];
// Same annotation, same two reasons as `filteredActions` above.
const actions: UIActionSchema[] = schema.systemActions || [];
const seen = new Set<string>();
// Chrome or not, a declared capability gates it (ADR-0066 D4) — a host
// that puts a gated action in this slot means the same thing by it.
Expand All @@ -203,7 +224,7 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
: (schema.maxVisible ?? 3);
const { inlineActions, overflowActions } = useMemo(() => {
if (filteredActions.length <= maxVisible) {
return { inlineActions: filteredActions, overflowActions: [] as ActionSchema[] };
return { inlineActions: filteredActions, overflowActions: [] as UIActionSchema[] };
}
return {
inlineActions: filteredActions.slice(0, maxVisible),
Expand All @@ -214,11 +235,11 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
// Merge business overflow with system actions into a single overflow list.
// Insert a visual separator before the first system action when both
// groups coexist, so users can distinguish domain vs. chrome actions.
const combinedOverflow = useMemo<ActionSchema[]>(() => {
const combinedOverflow = useMemo<UIActionSchema[]>(() => {
if (systemActions.length === 0) return overflowActions;
if (overflowActions.length === 0) return systemActions;
const [firstSys, ...restSys] = systemActions;
const firstWithSeparator: ActionSchema = {
const firstWithSeparator: UIActionSchema = {
...firstSys,
tags: [...(firstSys.tags || []), 'separator-before'],
};
Expand Down
25 changes: 17 additions & 8 deletions packages/components/src/renderers/action/action-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import React, { forwardRef, useCallback, useState } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { ActionSchema, ActionGroup, ActionLocation } from '@object-ui/types';
import type { ActionSchema, UIActionSchema, ActionGroup, ActionLocation } from '@object-ui/types';
import { actionRendersAt } from '@object-ui/types';
import { useAction } from '@object-ui/react';
import { useCondition, toPredicateInput, usePredicateRecordContext } from '@object-ui/react';
Expand Down Expand Up @@ -64,10 +64,10 @@ export interface ActionGroupSchema {
* Inline action button within a group.
*/
const InlineActionButton: React.FC<{
action: ActionSchema;
action: UIActionSchema;
variant?: string;
size?: string;
onExecute: (action: ActionSchema) => Promise<void>;
onExecute: (action: UIActionSchema) => Promise<void>;
/** The row the group is mounted over — see `DropdownActionItem` (objectui#4075). */
record?: unknown;
}> = ({ action, variant, size, onExecute, record }) => {
Expand Down Expand Up @@ -147,9 +147,9 @@ InlineActionButton.displayName = 'InlineActionButton';
* showed even when its predicate was false.
*/
export const DropdownActionItem: React.FC<{
action: ActionSchema;
action: UIActionSchema;
index: number;
onSelect: (action: ActionSchema) => void | Promise<void>;
onSelect: (action: UIActionSchema) => void | Promise<void>;
/**
* The row this group is mounted over, forwarded by the host. Optional: an
* object-level group genuinely has no row, and a predicate over an empty
Expand Down Expand Up @@ -232,10 +232,19 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// Placement is `actionRendersAt`'s call (objectui#3142) — this used to
// show an action with `locations: undefined` while hiding one with
// `locations: []`, a third reading of the same key.
const actions = (schema.actions || []).filter(a => actionRendersAt(a, schema.location));
// Annotated, not inferred, and `UIActionSchema` rather than the legacy
// `ActionSchema` — see the long derivation on `action:bar`'s equivalent
// line (objectui#4353). In short: `forwardRef` routes props through
// `PropsWithoutRef`, whose `Omit` collapses a props type carrying
// `[key: string]: any` to the bare index signature, so `schema` arrives as
// `any`; and the legacy type has no `locations`, which `actionRendersAt`
// on the very next line requires. One annotation where the list enters
// types both display modes' `.map()` callbacks below by inference.
const declaredActions: UIActionSchema[] = schema.actions || [];
const actions = declaredActions.filter(a => actionRendersAt(a, schema.location));

const handleExecute = useCallback(
async (action: ActionSchema) => {
async (action: UIActionSchema) => {
await execute({
type: action.type,
name: action.name,
Expand Down Expand Up @@ -272,7 +281,7 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// Dropdown items share the trigger's loading spinner, so wrap execution to
// toggle `dropdownLoading` (inline items manage their own local loading).
const handleDropdownSelect = useCallback(
async (action: ActionSchema) => {
async (action: UIActionSchema) => {
setDropdownLoading(true);
try {
await handleExecute(action);
Expand Down
21 changes: 14 additions & 7 deletions packages/components/src/renderers/action/action-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import React, { forwardRef, useCallback, useMemo, useState } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { ActionSchema } from '@object-ui/types';
import type { ActionSchema, UIActionSchema } from '@object-ui/types';
import { useAction } from '@object-ui/react';
import { useCondition, toPredicateInput, usePredicateRecordContext } from '@object-ui/react';
import { useObjectTranslation } from '@object-ui/i18n';
Expand Down Expand Up @@ -67,8 +67,8 @@ export interface ActionMenuSchema {
* `action-group.tsx`, whose gate is the same one.
*/
export const ActionMenuItem: React.FC<{
action: ActionSchema;
onExecute: (action: ActionSchema) => Promise<void>;
action: UIActionSchema;
onExecute: (action: UIActionSchema) => Promise<void>;
/**
* The row this menu is mounted over, forwarded by the host. Optional: an
* object-level menu genuinely has no row, and a predicate over an empty
Expand Down Expand Up @@ -162,8 +162,8 @@ ActionMenuItem.displayName = 'ActionMenuItem';
* and fire it twice, where `action:button`'s long-lived ref fires once.
*/
const ActionAutoTrigger: React.FC<{
action: ActionSchema;
onExecute: (action: ActionSchema) => Promise<void>;
action: UIActionSchema;
onExecute: (action: UIActionSchema) => Promise<void>;
}> = ({ action, onExecute }) => {
const run = useCallback(() => onExecute(action), [action, onExecute]);
useAutoTriggerOnce(hasAutoTrigger(action), run);
Expand Down Expand Up @@ -205,7 +205,7 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
const size = schema.size || 'icon';

const handleExecute = useCallback(
async (action: ActionSchema) => {
async (action: UIActionSchema) => {
setLoading(true);
try {
// UI-local escape hatch: direct callback, bypass ActionEngine
Expand Down Expand Up @@ -255,7 +255,14 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch

if (schema.visible && !isVisible) return null;

const actions = schema.actions || [];
// Annotated, not inferred, and `UIActionSchema` rather than the legacy
// `ActionSchema` — see the long derivation on `action:bar`'s equivalent
// line (objectui#4353). `forwardRef` routes props through
// `PropsWithoutRef`, whose `Omit` collapses a props type carrying
// `[key: string]: any` to the bare index signature, so `schema` arrives as
// `any`. One annotation where the list enters types both `.map()`
// callbacks below by inference.
const actions: UIActionSchema[] = schema.actions || [];
if (actions.length === 0) return null;

return (
Expand Down
4 changes: 2 additions & 2 deletions packages/components/src/renderers/data-display/tree-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ const TreeNodeComponent = ({
);
};

ComponentRegistry.register('tree-view',
({ schema, className, ...props }) => {
ComponentRegistry.register('tree-view',
({ schema, className, ...props }: { schema: TreeViewSchema; className?: string; [key: string]: any }) => {
const handleNodeClick = (node: TreeNode) => {
if (schema.onNodeClick) {
schema.onNodeClick(node);
Expand Down
48 changes: 27 additions & 21 deletions packages/components/src/renderers/navigation/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
*/

import { ComponentRegistry } from '@object-ui/core';
import type { SidebarSchema } from '@object-ui/types';
// `SidebarSchema` types the one entry point the registry actually maps to a
// schema (`'sidebar'` — see `@object-ui/types`' registry map). The other ten
// entry points below are sidebar PARTS, which have no schema type of their own;
// they take `BaseSchema`, the type every registered node satisfies. Using
// `SidebarSchema` for them would assert `type: 'sidebar'` on a node whose type
// is `'sidebar-header'` (objectui#4353).
import type { SidebarSchema, BaseSchema } from '@object-ui/types';
import { renderChildren } from '../../lib/utils';
import {
SidebarProvider,
Expand All @@ -25,8 +31,8 @@ import {
SidebarInset
} from '../../ui';

ComponentRegistry.register('sidebar-provider',
({ schema, ...props }) => (
ComponentRegistry.register('sidebar-provider',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarProvider {...props}>{renderChildren(schema.body)}</SidebarProvider>
),
{
Expand Down Expand Up @@ -70,8 +76,8 @@ ComponentRegistry.register('sidebar',
}
);

ComponentRegistry.register('sidebar-header',
({ schema, ...props }) => (
ComponentRegistry.register('sidebar-header',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarHeader {...props}>{renderChildren(schema.body)}</SidebarHeader>
),
{
Expand All @@ -83,8 +89,8 @@ ComponentRegistry.register('sidebar-header',
}
);

ComponentRegistry.register('sidebar-content',
({ schema, ...props }) => (
ComponentRegistry.register('sidebar-content',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarContent {...props}>{renderChildren(schema.body)}</SidebarContent>
),
{
Expand All @@ -96,8 +102,8 @@ ComponentRegistry.register('sidebar-content',
}
);

ComponentRegistry.register('sidebar-group',
({ schema, ...props }) => (
ComponentRegistry.register('sidebar-group',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarGroup {...props}>
{schema.label && <SidebarGroupLabel>{schema.label}</SidebarGroupLabel>}
<SidebarGroupContent>
Expand All @@ -120,8 +126,8 @@ ComponentRegistry.register('sidebar-group',
}
);

ComponentRegistry.register('sidebar-menu',
({ schema, ...props }) => (
ComponentRegistry.register('sidebar-menu',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarMenu {...props}>{renderChildren(schema.body)}</SidebarMenu>
),
{
Expand All @@ -134,8 +140,8 @@ ComponentRegistry.register('sidebar-menu',
}
);

ComponentRegistry.register('sidebar-menu-item',
({ schema, ...props }) => (
ComponentRegistry.register('sidebar-menu-item',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarMenuItem {...props}>{renderChildren(schema.body)}</SidebarMenuItem>
),
{
Expand All @@ -147,8 +153,8 @@ ComponentRegistry.register('sidebar-menu-item',
}
);

ComponentRegistry.register('sidebar-menu-button',
({ schema, ...props }) => (
ComponentRegistry.register('sidebar-menu-button',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarMenuButton isActive={schema.active} {...props}>
{renderChildren(schema.body)}
</SidebarMenuButton>
Expand All @@ -170,8 +176,8 @@ ComponentRegistry.register('sidebar-menu-button',
}
);

ComponentRegistry.register('sidebar-footer',
({ schema, ...props }) => (
ComponentRegistry.register('sidebar-footer',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarFooter {...props}>{renderChildren(schema.body)}</SidebarFooter>
),
{
Expand All @@ -183,8 +189,8 @@ ComponentRegistry.register('sidebar-footer',
}
);

ComponentRegistry.register('sidebar-inset',
({ schema, ...props }) => (
ComponentRegistry.register('sidebar-inset',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarInset {...props}>{renderChildren(schema.body)}</SidebarInset>
),
{
Expand All @@ -196,8 +202,8 @@ ComponentRegistry.register('sidebar-inset',
}
);

ComponentRegistry.register('sidebar-trigger',
({ className, ...props }) => (
ComponentRegistry.register('sidebar-trigger',
({ className, ...props }: { className?: string; [key: string]: any }) => (
<SidebarTrigger className={className} {...props} />
),
{
Expand Down
1 change: 0 additions & 1 deletion packages/components/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"@/*": ["src/*"]
},
// Removed rootDir to prevent file not under rootDir errors when importing from ..
"noImplicitAny": false,
"noEmit": false,
"declaration": true,
"composite": true,
Expand Down
Loading
Loading