Skip to content

Commit ab0c5df

Browse files
refactor(plugin-list): ListView reads its view blocks through ListViewSchema, not a (schema as any) cast (#28335)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuRHF1GswWyuFi1iHok1ht8c)_ Part of #25918 Clause-②: no Metadata-protocol: none Skills-docs: none ## 维护者速读 - 改了什么:`ListView.tsx` 里 26 处 `(schema as any)` 读取(19 行代码;卡上数的 21 行另含 2 行注释)全部去掉。kanban、calendar、gallery、timeline、gantt、tree、chart 和 `bulkActionDefs` 都是 `ListViewSchema` 已声明的键,改成直接的类型读取;唯一未声明的 `rowActionDefs`(宿主拼的键,规范在列表面按名拒收)改走一个窄类型转换 `hostRowActionDefsOf`,类型取自消费它的 `object-grid` 节点。加了一个源码扫描测试,防止 `(schema as any)` 再回来。 - 为什么改:`any` 转换把类型检查整段关掉了,键名拼错或用法不对都照样编译通过;这些键其实都有类型,转换什么也没挡住,只挡住了类型。 - 风险与回滚:运行时行为不变,列表渲染和取数都不变(只是读法变了;新增的小函数返回同一个值)。回滚即还原本 PR。 - 先看:`ui/packages/plugin-list/src/ListView.tsx`(`hostRowActionDefsOf` 与 3360、4210 附近两处 `rowActionDefs` 读取)、`ui/packages/plugin-list/src/__tests__/ListView.noAnySchemaCast.test.ts`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-list/src/__tests__/ListView.noAnySchemaCast.test.ts packages/plugin-list/src/__tests__/listViewNonAuthorKeys-8653.test.tsx`,预期全绿:扫描找不到 `(schema as any)`,`rowActionDefs` 仍传到表格节点并进入 `$select`。 - 需要你做的:无,席位落地 ## What changes - `ListView.tsx` reads every key it used to read through `(schema as any)` as a typed member of `ListViewSchema`: `kanban`, `calendar`, `gallery`, `timeline`, `gantt` (the projection and expand collectors and the fetch effect's dependencies), `tree` and `chart` (the capability gate and the tree branch), and `bulkActionDefs` (the predicate harvest and the grid hand-off). Each is a declared member, checked with the TypeScript checker against the built `@object-ui/types` declarations (index signature stripped), not by grep. - `rowActionDefs` is the one key declared on no list mirror: the spec refuses it by name on the list surface, and the objectui#8653 ruling keeps the read (the host composes it; it reaches the child `object-grid` node and `$select`). It is now read through one module-level helper, `hostRowActionDefsOf`, that casts to `Pick<DataTableSchema, 'rowActionDefs'>`, the type the consuming grid node declares. The predicate-key site's own `{ rowActionDefs?: readonly unknown[] }` cast uses the same helper, so the file has one spelling for that read. - No key is declared in `@object-ui/types` or `@objectstack/spec`; no exported type changes (the helper is module-private). - Two comments still quote the old spelling (the `userActions` collision note and the retired `title` arm). They are prose describing reads that must not come back, not reads, and are left as written. ## Tests - New `ListView.noAnySchemaCast.test.ts`: a TypeScript AST walk of `ListView.tsx` finds no `schema as any` / `props.schema as any` cast. A firing control on a probe shows it catches the plain, double-parenthesised and `props.schema` shapes, and skips comments, typed casts and other objects. - Ablation, after committing the fix: `scripts/ablation-replace.mjs` put back one cast (`(schema as any).tree?.parentField`) and the new test went red naming that line; restored blob equals HEAD. With `origin/main`'s `ListView.tsx` written over the file, the scan names 26 casts on 19 lines (the card's 21 grep lines less the 2 comment lines; some lines carry two casts); restored, `git diff --quiet HEAD` clean. - The existing pins on these reads stay green unchanged, `listViewNonAuthorKeys-8653` (`rowActionDefs` still relayed to the grid and harvested into `$select`) and `ListView.harvestInputsFetchKey-10689` among them. ## Acceptance notes - Out of scope and untouched, per triage 6407890835: "⛔ Out of scope until the maintainer names it: `no-explicit-any: 'error'` on `packages/types/src`." No ESLint config change. - Noted only: the predicate-key site still reads `bulkActionDefs` through a narrow `{ bulkActionDefs?: readonly unknown[] }` cast. It is not an `any` cast and is outside this card's surface; `schema.bulkActionDefs` would also compile there. - The branch is the platform-assigned card-thread branch (`claude/project-thread-epslnx`), the measured exception to `claude/issue-N-slug` named in the claim. ## Verification Head `0a1ec22650` (main `b33946512c` merged in, which brought the kanban `lanes` change to `ListView.tsx`; the merge was clean and added no cast). Every reading below is on this head unless it says otherwise. <details><summary>type-check, tests, lint</summary> - Closure `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-list^...' build` under os-verify-lock: VERDICT command-exit 0 (also on the two earlier heads). - `pnpm --filter @object-ui/plugin-list run type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0 (also on the two earlier heads). - `pnpm --dir ui exec vitest run --maxWorkers=3 packages/plugin-list/` plus the 27 out-of-package test files that read `ListView.tsx` off disk, under os-verify-lock, VERDICT command-exit 0, `Test Files 202 passed (202)`, `Tests 2337 passed (2337)` (201 / 2335 on `09ae34358a`, before main's new kanban test arrived). - `eslint` on `ListView.tsx` and the new test: 0 errors. `ListView.tsx` warnings 190 to 150 (`no-explicit-any` 132 to 106, `react-hooks/exhaustive-deps` 11 to 4, `react-hooks/use-memo` 7 to 0); the new test has none. - `node scripts/check-sdui-manifest.mjs`: exit 0, manifest intact. </details> <details><summary>dispatch gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `0a1ec22650`: 34 commands derived (3 changed paths, 138 changed lines, under the 3000-line threshold); each run with its exit code captured before any pipe: all 34 exit 0. - `--ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every entry carries an exit code). - The same 34 also ran on `e4ac205e62` and `09ae34358a`: all exit 0. - `dispatch-gates --tier`: no path-derived mandate (as quoted in the claim). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgTB22iDFvxkkThZWCa)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgTB22iDFvxkkThZWCa Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6762dd9 commit ab0c5df

3 files changed

Lines changed: 117 additions & 21 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
---
4+
5+
refactor(plugin-list): `ListView` reads its view blocks through `ListViewSchema` instead of a `(schema as any)` cast
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The kanban, calendar, gallery, timeline, gantt, tree and chart blocks and `bulkActionDefs` are declared members of `ListViewSchema`, so `ListView` now reads them as typed members. The host-composed `rowActionDefs`, which the spec refuses on the list surface, is read through one narrow cast typed as the `object-grid` node declares it. Nothing a list view renders or fetches changes.

‎ui/packages/plugin-list/src/ListView.tsx‎

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useDataInvalidation, useFilterScope, useResolvedFilter, NonGridRowCeilingNote } from '@object-ui/react';
3030
import type { LoadErrorKind } from '@object-ui/react';
3131
import { useDensityMode, resolveInlineAriaProps } from '@object-ui/react';
32-
import type { DependsOnInput, ListViewSchema, ObjectMapConfig } from '@object-ui/types';
32+
import type { DataTableSchema, DependsOnInput, ListViewSchema, ObjectMapConfig } from '@object-ui/types';
3333
import { detectStatusField, isSystemManagedField } from '@object-ui/types';
3434
import { usePullToRefresh } from '@object-ui/mobile';
3535
import { type ListViewVisualization, resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveAffordance, type SchemaLike, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, leadWithNameField, filterPlatformSortableSort, applyNonGridRowCeiling, type NonGridCeilingResult, resolveNameField, declaredNameField, resolveDependsOnFields, GANTT_CONFIG_SPELLING, kanbanDeclaredLanes } from '@object-ui/core';
@@ -1270,7 +1270,16 @@ export const DEFAULT_LIST_FETCH_BATCH_SIZE = 100;
12701270
* built on and the lane its query is gated on are one answer.
12711271
*/
12721272
function kanbanBlockOf(schema: ListViewSchema): Record<string, any> {
1273-
return { ...((schema as any).kanban || {}) };
1273+
return { ...(schema.kanban || {}) };
1274+
}
1275+
1276+
/**
1277+
* The host-composed `rowActionDefs` (objectui#8653): declared on no list
1278+
* mirror because the spec refuses it by name, so this is its one narrow cast,
1279+
* typed as the `object-grid` child that consumes it declares it.
1280+
*/
1281+
function hostRowActionDefsOf(schema: ListViewSchema): DataTableSchema['rowActionDefs'] {
1282+
return (schema as Pick<DataTableSchema, 'rowActionDefs'>).rowActionDefs;
12741283
}
12751284

12761285
/**
@@ -2587,11 +2596,11 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
25872596
if (typeof f === 'string' && f) collected.add(f);
25882597
}
25892598
};
2590-
collectViewFields((schema as any).kanban);
2591-
collectViewFields((schema as any).calendar);
2592-
collectViewFields((schema as any).gallery);
2593-
collectViewFields((schema as any).timeline);
2594-
collectViewFields((schema as any).gantt);
2599+
collectViewFields(schema.kanban);
2600+
collectViewFields(schema.calendar);
2601+
collectViewFields(schema.gallery);
2602+
collectViewFields(schema.timeline);
2603+
collectViewFields(schema.gantt);
25952604
// [objectui#10370] The map, read through the resolver its render branch
25962605
// and capability gate share — see the `$select` twin below for why the
25972606
// resolver rather than the two blocks one at a time. A coordinate or title
@@ -2656,11 +2665,11 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
26562665
objectDef?.fields,
26572666
groupingConfig,
26582667
schema.columns,
2659-
(schema as any).kanban,
2660-
(schema as any).calendar,
2661-
(schema as any).gallery,
2662-
(schema as any).timeline,
2663-
(schema as any).gantt,
2668+
schema.kanban,
2669+
schema.calendar,
2670+
schema.gallery,
2671+
schema.timeline,
2672+
schema.gantt,
26642673
schema.map,
26652674
perms,
26662675
schema.objectName,
@@ -2913,7 +2922,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
29132922
// key. `__tests__/ListView.harvestInputsFetchKey-10689.test.tsx` pins each
29142923
// input.
29152924
const conditionalFormattingRaw = schema.conditionalFormatting as readonly unknown[] | undefined;
2916-
const rowActionDefsRaw = (schema as { rowActionDefs?: readonly unknown[] }).rowActionDefs;
2925+
const rowActionDefsRaw = hostRowActionDefsOf(schema);
29172926
const bulkActionDefsRaw = (schema as { bulkActionDefs?: readonly unknown[] }).bulkActionDefs;
29182927
const predicateProjectionKey = React.useMemo(
29192928
() => JSON.stringify(collectPredicateFieldRefs(listViewPredicates({
@@ -3353,8 +3362,8 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
33533362
* `__tests__/listViewNonAuthorKeys-8653.test.tsx` §4 SITE 2; the
33543363
* declared sibling `bulkActionDefs` is that pin's control.
33553364
*/
3356-
rowActionDefs: (schema as any).rowActionDefs,
3357-
bulkActionDefs: (schema as any).bulkActionDefs,
3365+
rowActionDefs: hostRowActionDefsOf(schema),
3366+
bulkActionDefs: schema.bulkActionDefs,
33583367
objectActions: (objectDef as any)?.actions,
33593368
// KEY COLLISION — `userActions` names TWO different blocks, and
33603369
// only the OBJECT's is interpretable here. This read is therefore
@@ -3645,7 +3654,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
36453654
// silently un-suppresses nothing, because the finding it was suppressing
36463655
// simply moves elsewhere. Add prose ABOVE this point, never below it.
36473656
// eslint-disable-next-line react-hooks/exhaustive-deps
3648-
}, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on
3657+
}, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, schema.kanban, schema.calendar, schema.gallery, schema.timeline, schema.gantt, schema.map, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on
36493658

36503659
// Any change to the result-defining inputs (object, filters, sort, search,
36513660
// grouping, page size) invalidates the current page number — snap back to
@@ -3723,7 +3732,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
37233732
}
37243733

37253734
// Check for Tree capabilities — a self-referencing parent pointer.
3726-
if ((schema as any).tree?.parentField || schema.viewType === 'tree') {
3735+
if (schema.tree?.parentField || schema.viewType === 'tree') {
37273736
resolvable.push('tree');
37283737
}
37293738

@@ -3764,7 +3773,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
37643773
}
37653774

37663775
return resolvable;
3767-
}, [schema.viewType, schema.kanban, schema.calendar, schema.gantt, schema.gallery, schema.timeline, schema.map, (schema as any).tree, (schema as any).chart, schema.appearance?.allowedVisualizations]);
3776+
}, [schema.viewType, schema.kanban, schema.calendar, schema.gantt, schema.gallery, schema.timeline, schema.map, schema.tree, schema.chart, schema.appearance?.allowedVisualizations]);
37683777

37693778
/**
37703779
* Whether the visualization switcher is actually WORTH drawing (objectui#7547).
@@ -4199,9 +4208,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
41994208
* `__tests__/listViewNonAuthorKeys-8653.test.tsx` §4 SITE 1, with
42004209
* the declared `bulkActionDefs` below as that pin's control.
42014210
*/
4202-
...((schema as any).rowActionDefs ? { rowActionDefs: (schema as any).rowActionDefs } : {}),
4211+
...(hostRowActionDefsOf(schema) ? { rowActionDefs: hostRowActionDefsOf(schema) } : {}),
42034212
...(schema.bulkActions ? { batchActions: schema.bulkActions } : {}),
4204-
...((schema as any).bulkActionDefs ? { bulkActionDefs: (schema as any).bulkActionDefs } : {}),
4213+
...(schema.bulkActionDefs ? { bulkActionDefs: schema.bulkActionDefs } : {}),
42054214
// ⛔ No `options.grid` spread (objectui#6152 round 14). A grid has no
42064215
// per-kind block: the spec refuses the whole `options` bag (#25923;
42074216
// `options.grid` since round 12), and the raw spread that still put
@@ -4630,7 +4639,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
46304639
// Self-referencing tree-grid. Config lives under view.tree.* (the
46314640
// `options.tree` bag retired in #25923). parentField auto-detects
46324641
// from the object's tree/self-reference field when omitted.
4633-
const treeCfg = (schema as any).tree || {};
4642+
const treeCfg = schema.tree || {};
46344643
return {
46354644
type: 'object-tree',
46364645
...baseProps,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `ListView.tsx` reads its schema through the type, never through
11+
* `(schema as any)` (objectstack#25918).
12+
*
13+
* Every key the file used to read through the `any` cast (`kanban`,
14+
* `calendar`, `gallery`, `timeline`, `gantt`, `tree`, `chart`,
15+
* `bulkActionDefs`) is a declared member of `ListViewSchema`, so the cast hid
16+
* nothing but the type: a misspelt key or a wrong-shaped use compiled. The one
17+
* undeclared key, the host-composed `rowActionDefs`, is refused by the spec on
18+
* the list surface (objectui#8653), so it is read through a single narrow cast
19+
* (`hostRowActionDefsOf`), typed as the `object-grid` child declares it.
20+
*
21+
* The scan is a TypeScript AST walk, so the prose that quotes a retired cast
22+
* (the `userActions` collision note, the retired `title` arm) is trivia and
23+
* does not count.
24+
*/
25+
import { describe, it, expect } from 'vitest';
26+
import ts from 'typescript';
27+
import { readFileSync } from 'node:fs';
28+
import { dirname, join } from 'node:path';
29+
import { fileURLToPath } from 'node:url';
30+
31+
/** Rooted at THIS file, never at `process.cwd()`. */
32+
const LIST_VIEW = join(dirname(fileURLToPath(import.meta.url)), '..', 'ListView.tsx');
33+
34+
/** `x`, with any wrapping parentheses taken off. */
35+
function unparen(node: ts.Expression): ts.Expression {
36+
let n = node;
37+
while (ts.isParenthesizedExpression(n)) n = n.expression;
38+
return n;
39+
}
40+
41+
/** The 1-based lines of every `schema as any` / `props.schema as any` cast in `text`. */
42+
function anySchemaCasts(text: string, file: string): number[] {
43+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
44+
const hits: number[] = [];
45+
const walk = (node: ts.Node): void => {
46+
if (ts.isAsExpression(node) && node.type.kind === ts.SyntaxKind.AnyKeyword) {
47+
const operand = unparen(node.expression);
48+
const isSchema =
49+
(ts.isIdentifier(operand) && operand.text === 'schema') ||
50+
(ts.isPropertyAccessExpression(operand) && operand.name.text === 'schema');
51+
if (isSchema) hits.push(sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1);
52+
}
53+
ts.forEachChild(node, walk);
54+
};
55+
walk(sf);
56+
return hits;
57+
}
58+
59+
describe('ListView.tsx reads its schema through the type (objectstack#25918)', () => {
60+
it('no `(schema as any)` cast is left', () => {
61+
const hits = anySchemaCasts(readFileSync(LIST_VIEW, 'utf8'), 'ListView.tsx')
62+
.map((line) => `plugin-list/src/ListView.tsx line ${line}`);
63+
expect(hits, 'read a declared key as schema.KEY; an undeclared key takes a narrow typed cast').toEqual([]);
64+
});
65+
66+
it('FIRING CONTROL: the scanner catches each cast shape, and skips comments, typed casts and other objects', () => {
67+
const probe = [
68+
'// (schema as any).kanban in a comment is prose',
69+
'const a = (schema as any).kanban;',
70+
'const b = ((schema as any)).tree?.parentField;',
71+
'const c = (props.schema as any).chart;',
72+
'const d = (schema as Pick<DataTableSchema, "rowActionDefs">).rowActionDefs;',
73+
'const e = (field as any).options;',
74+
].join('\n');
75+
expect(anySchemaCasts(probe, 'probe.ts')).toEqual([2, 3, 4]);
76+
});
77+
});

0 commit comments

Comments
 (0)