From 45b606553c8815374b8e832cf6cc6c05f4a9502e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 05:15:11 +0000 Subject: [PATCH] feat(components): compile under noImplicitAny (#4353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/components/tsconfig.json` was the only place in the workspace relaxing a `strict` sub-flag, under a comment that explained the adjacent `rootDir` removal rather than the flag. `tsconfig.test.json` mirrored the one flag deliberately, so the test project could not become the compiler of record for a source strictness decision the build config owns. Both configs now inherit `strict: true` from the root. The flag flip reported 26 implicitly-`any` sites in five renderer sources and 2 in the package's own tests; all 28 are typed. Types only — no runtime change. The sidebar entry points follow the package's measured convention (an inline `{ schema: Schema; [key: string]: any }` naming the registered component's schema type, 21 occurrences). The ten sidebar PARTS have no schema type of their own and take `BaseSchema`; `SidebarSchema` would assert `type: 'sidebar'` on a `'sidebar-header'` node. The action callbacks are typed from `UIActionSchema`, not the legacy `ActionSchema` these files import for their declarations: the legacy interface has no `locations` (which `actionRendersAt` requires), no `'primary'` variant (which the #2339 tie-break compares against), and a literal `type: 'action'` where the actions flowing through carry `'form' | 'script' | 'url' | 'flow' | 'api' | 'modal'`. That was unverifiable before, because `forwardRef` routes props through `PropsWithoutRef`, whose `Omit` collapses a props type carrying `[key: string]: any` to the bare index signature — so `schema` arrived as `any` and every callback under it inferred `any`. Nothing this package publishes changes shape: the three action schema interfaces and the leaf components are not re-exported from `src/index.ts`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/components-no-implicit-any-4353.md | 17 +++++++ .../div-deprecation-warn-once.test.tsx | 5 +- .../page-header-predicate-dialect.test.tsx | 5 +- .../src/renderers/action/action-bar.tsx | 33 ++++++++++--- .../src/renderers/action/action-group.tsx | 25 ++++++---- .../src/renderers/action/action-menu.tsx | 21 +++++--- .../src/renderers/data-display/tree-view.tsx | 4 +- .../src/renderers/navigation/sidebar.tsx | 48 +++++++++++-------- packages/components/tsconfig.json | 1 - packages/components/tsconfig.test.json | 22 +++++---- 10 files changed, 124 insertions(+), 57 deletions(-) create mode 100644 .changeset/components-no-implicit-any-4353.md diff --git a/.changeset/components-no-implicit-any-4353.md b/.changeset/components-no-implicit-any-4353.md new file mode 100644 index 0000000000..a818ba7bce --- /dev/null +++ b/.changeset/components-no-implicit-any-4353.md @@ -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: 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. diff --git a/packages/components/src/__tests__/div-deprecation-warn-once.test.tsx b/packages/components/src/__tests__/div-deprecation-warn-once.test.tsx index 58b92ffc12..24695ec83d 100644 --- a/packages/components/src/__tests__/div-deprecation-warn-once.test.tsx +++ b/packages/components/src/__tests__/div-deprecation-warn-once.test.tsx @@ -43,7 +43,10 @@ function renderDiv(schema: Record) { } function deprecationCalls(spy: ReturnType): unknown[][] { - return spy.mock.calls.filter((args) => DEPRECATION_RE.test(String(args[0]))); + // `ReturnType` 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)', () => { diff --git a/packages/components/src/__tests__/page-header-predicate-dialect.test.tsx b/packages/components/src/__tests__/page-header-predicate-dialect.test.tsx index 0b47d487f3..f32d693fec 100644 --- a/packages/components/src/__tests__/page-header-predicate-dialect.test.tsx +++ b/packages/components/src/__tests__/page-header-predicate-dialect.test.tsx @@ -244,8 +244,11 @@ describe('page:header — legacy dialect still falls back (#3521)', () => { return () => warn.mockRestore(); }); + // `ReturnType` 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"}' }); diff --git a/packages/components/src/renderers/action/action-bar.tsx b/packages/components/src/renderers/action/action-bar.tsx index b06e2e9e12..15397ec32f 100644 --- a/packages/components/src/renderers/action/action-bar.tsx +++ b/packages/components/src/renderers/action/action-bar.tsx @@ -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'; @@ -129,7 +129,27 @@ const ActionBarRenderer = forwardRef { - 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 @@ -183,7 +203,8 @@ const ActionBarRenderer = forwardRef { - const actions = schema.systemActions || []; + // Same annotation, same two reasons as `filteredActions` above. + const actions: UIActionSchema[] = schema.systemActions || []; const seen = new Set(); // 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. @@ -203,7 +224,7 @@ const ActionBarRenderer = forwardRef { if (filteredActions.length <= maxVisible) { - return { inlineActions: filteredActions, overflowActions: [] as ActionSchema[] }; + return { inlineActions: filteredActions, overflowActions: [] as UIActionSchema[] }; } return { inlineActions: filteredActions.slice(0, maxVisible), @@ -214,11 +235,11 @@ const ActionBarRenderer = forwardRef(() => { + const combinedOverflow = useMemo(() => { 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'], }; diff --git a/packages/components/src/renderers/action/action-group.tsx b/packages/components/src/renderers/action/action-group.tsx index db533b560f..f4aad374ef 100644 --- a/packages/components/src/renderers/action/action-group.tsx +++ b/packages/components/src/renderers/action/action-group.tsx @@ -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'; @@ -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; + onExecute: (action: UIActionSchema) => Promise; /** The row the group is mounted over — see `DropdownActionItem` (objectui#4075). */ record?: unknown; }> = ({ action, variant, size, onExecute, record }) => { @@ -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; + onSelect: (action: UIActionSchema) => void | Promise; /** * 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 @@ -232,10 +232,19 @@ const ActionGroupRenderer = forwardRef 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, @@ -272,7 +281,7 @@ const ActionGroupRenderer = forwardRef { + async (action: UIActionSchema) => { setDropdownLoading(true); try { await handleExecute(action); diff --git a/packages/components/src/renderers/action/action-menu.tsx b/packages/components/src/renderers/action/action-menu.tsx index 7ab54ae94b..6469a7b23c 100644 --- a/packages/components/src/renderers/action/action-menu.tsx +++ b/packages/components/src/renderers/action/action-menu.tsx @@ -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'; @@ -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; + action: UIActionSchema; + onExecute: (action: UIActionSchema) => Promise; /** * 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 @@ -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; + action: UIActionSchema; + onExecute: (action: UIActionSchema) => Promise; }> = ({ action, onExecute }) => { const run = useCallback(() => onExecute(action), [action, onExecute]); useAutoTriggerOnce(hasAutoTrigger(action), run); @@ -205,7 +205,7 @@ const ActionMenuRenderer = forwardRef { + async (action: UIActionSchema) => { setLoading(true); try { // UI-local escape hatch: direct callback, bypass ActionEngine @@ -255,7 +255,14 @@ const ActionMenuRenderer = forwardRef { +ComponentRegistry.register('tree-view', + ({ schema, className, ...props }: { schema: TreeViewSchema; className?: string; [key: string]: any }) => { const handleNodeClick = (node: TreeNode) => { if (schema.onNodeClick) { schema.onNodeClick(node); diff --git a/packages/components/src/renderers/navigation/sidebar.tsx b/packages/components/src/renderers/navigation/sidebar.tsx index eb4f5f4a1c..942ce9b734 100644 --- a/packages/components/src/renderers/navigation/sidebar.tsx +++ b/packages/components/src/renderers/navigation/sidebar.tsx @@ -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, @@ -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 }) => ( {renderChildren(schema.body)} ), { @@ -70,8 +76,8 @@ ComponentRegistry.register('sidebar', } ); -ComponentRegistry.register('sidebar-header', - ({ schema, ...props }) => ( +ComponentRegistry.register('sidebar-header', + ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( {renderChildren(schema.body)} ), { @@ -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 }) => ( {renderChildren(schema.body)} ), { @@ -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 }) => ( {schema.label && {schema.label}} @@ -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 }) => ( {renderChildren(schema.body)} ), { @@ -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 }) => ( {renderChildren(schema.body)} ), { @@ -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 }) => ( {renderChildren(schema.body)} @@ -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 }) => ( {renderChildren(schema.body)} ), { @@ -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 }) => ( {renderChildren(schema.body)} ), { @@ -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 }) => ( ), { diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index b8fa0ef77c..9bdf087641 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -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, diff --git a/packages/components/tsconfig.test.json b/packages/components/tsconfig.test.json index 78817e643f..bd20339ade 100644 --- a/packages/components/tsconfig.test.json +++ b/packages/components/tsconfig.test.json @@ -34,16 +34,18 @@ // Naming `types` at all switches off automatic `@types/*` inclusion; // `@testing-library/jest-dom` is listed for the matchers the DOM suites use. "types": ["node", "@testing-library/jest-dom"], - // MIRRORS `tsconfig.json`, and only this one flag. A test file imports the - // package's own sources, so those sources are program inputs here too — and - // this package's BUILD config has `noImplicitAny: false`. Re-enabling it in - // this project would report 26 errors in `src/renderers/**` (sidebar, - // action-bar, action-menu, action-group, tree-view), i.e. it would make the - // TEST project the compiler of record for a SOURCE strictness decision that - // `tsconfig.json` owns. Those 26 are filed rather than silently adopted; - // tightening the package is a separate change to the build config, where the - // published output would actually move. - "noImplicitAny": false, + // `noImplicitAny` is NOT mirrored here any more, and the absence is the + // point. This project used to carry `"noImplicitAny": false` copied from + // `tsconfig.json`, because a test file imports the package's own sources — + // so those sources are program inputs here too, and re-enabling the flag in + // THIS project would have made the TEST project the compiler of record for a + // SOURCE strictness decision that `tsconfig.json` owns. The 26 renderer + // sites that mirror was protecting were filed as objectui#4353 rather than + // silently adopted, and #4353 typed them and turned the flag on in + // `tsconfig.json`. With the build config no longer relaxing it, a mirror + // here would invert the same hazard — this project would be relaxing what + // the source config tightened. Both projects now simply inherit + // `strict: true` from the root config. // Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and // `@objectstack/spec` resolve through the workspace dependency's built // `.d.ts` instead of pulling sibling sources in as program inputs (TS6059).