Skip to content

refactor(plugin-grid): derive the row-menu predicate declarations from the spec-owned authoring type (#4429) - #4430

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4429-grid-row-menu-derive
Aug 12, 2026
Merged

refactor(plugin-grid): derive the row-menu predicate declarations from the spec-owned authoring type (#4429)#4430
yinlianghui merged 1 commit into
mainfrom
claude/issue-4429-grid-row-menu-derive

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4429

Inherits PR #4423's pattern wholesale — the per-key Pick derivations for the planner, one union alias for the shared both-builtin consumers, the two principled styles, the DOM pin joining counting to rendering, the vacuous-case rename, and the RV method including the built-.d.ts rebuild trap.

The card said four hand-restated shapes; the file has ONE, read at SIX sites

Re-verified at this branch point (4dadf0d3d). The defect is real and is exactly the kind #4423 collapsed, but its shape differs from the data-table's and the reading governs: packages/components had four independent inline literals, while plugin-grid has a single hand-written export interface BuiltinRowActionPredicates consumed at six declaration sites. One definition, six readers — not four definitions.

#SiteWasReadsNow
BuiltinRowActionPredicates (the one hand-written declaration){ visibleWhen?: unknown; disabledWhen?: unknown }derived alias
1RowActionMenuProps.editPredicatesthe local interfaceforwards bothwhole derived shape
2RowActionMenuProps.deletePredicatesthe local interfaceforwards bothwhole derived shape
3isBuiltinRowActionVisiblethe local interfacevisibleWhenPick< …, 'visibleWhen' >
4planRowActionMenu.editPredicatesthe local interfacevisibleWhenper-key Pick from the edit twin
5planRowActionMenu.deletePredicatesthe local interfacevisibleWhenper-key Pick from the delete twin
6BuiltinRowActionItem.predicatesthe local interfacebothwhole derived shape

The card's fourth bullet ("a local disabledWhen?: unknown, ~line 65") is not a fifth site — it is one key inside that single interface, at exactly line 65. Counting it separately would double-count the declaration.

The measured TRUE source — not DataTableSchema

The card asked whether these predicates flow from DataTableSchema's twins or a grid-own key. Neither. This surface is never handed a DataTableSchema key at all:

ObjectGrid.tsx:1743 resolveRowCrudAffordances({ managedBy, userActions, … })
rowCrudAffordances.ts:157 editPredicates: canEdit ? aff.editPredicates : undefined
aff = resolveEffectiveCrudAffordances(…) -> CrudAffordances (@object-ui/core)
core/utils/managedBy.ts:53 export type { CrudAffordances, RowCrudPredicates }
re-exported verbatim from @objectstack/spec/data (ADR-0103)
ObjectGrid.tsx:1783 editPredicates={editPredicates} -> RowActionMenu

So the authoring source is the spec-ownedRowCrudPredicates, reached through CrudAffordances['editPredicates'] / ['deletePredicates'] — the very keys the production caller passes. Deriving from DataTableSchema.rowEditPredicates would have bound this file to a schema key it never sees: a NEW wrong coupling in place of the old missing one. @object-ui/types is untouched, and so is @object-ui/core — this derives from them.

The union of the two twins is kept for the reason #4423 gave: every consumer reading the alias serves BOTH built-ins (name: 'edit' | 'delete'), so it may only read keys that both affordance keys declare. Today the twins are declared as one named type, so the union collapses onto it — stated plainly rather than dressed up; the construction is what carries the constraint if that coincidence ever ends.

The derivation TIGHTENS, and that is the point

The hand-written pair typed both keys unknown; the spec types them Expression | ExpressionInput (the authored CEL shorthand, or its { dialect, source } envelope). @object-ui/core retired precisely this imprecision at its own seam and said so in as many words — "The local unknown was imprecision, not a deliberate dialect" (core/utils/managedBy.ts:43-52). This was the last copy of it.

Reverse verification

Direction predicted before running, and the fix taken out with git diff + git checkout -- / git apply — never git stash.

RV1 — the rename simulation

Renamed RowCrudPredicates.visibleWhen to visibleWhenRenamed at the source, then type-checked plugin-grid both ways, in both of its tsc projects.

Mechanical note, recorded for the next reader. The rename had to land in @objectstack/spec's declaration files, and those are pnpm-hardlinked (9 links each) into the shared store, so an in-place edit would have corrupted every parallel agent's tree. Both files were therefore replaced through a new inode (cp backup, write elsewhere, rename), verified at 1 link afterwards and restored the same way. The rebuild trap #4423 recorded did not bite here, and the reason is worth knowing: plugin-grid's tsconfig.test.json sets "paths": {}, so @object-ui/core resolves through its built .d.ts — but that built .d.tsre-exports the spec type rather than inlining it, so the rename propagated with no @object-ui/core rebuild. Verified empirically by the test project going red.

src projecttest project
Derived (this PR)6 errors7 errors
Hand-written (origin/main)00

Predicted: derived red at the declarations; hand-written fully silent. Measured exactly that — and the silence is total, stronger than #4423's data-table half (which at least emitted two incidental weak-type errors at call sites):

src/components/RowActionMenu.tsx(207,48): error TS2344: Type '"visibleWhen"' does not satisfy the constraint 'keyof RowCrudPredicates'.
src/components/RowActionMenu.tsx(342,73): error TS2344: Type '"visibleWhen"' does not satisfy the constraint 'keyof RowCrudPredicates'.
src/components/RowActionMenu.tsx(343,77): error TS2344: Type '"visibleWhen"' does not satisfy the constraint 'keyof RowCrudPredicates'.
src/components/RowActionMenu.tsx(455,37): error TS2345: …
src/components/RowActionMenu.tsx(592,9): error TS2322: …
src/components/RowActionMenu.tsx(593,9): error TS2322: …

The three TS2344s are the three derived declarations themselves — the compiler names the lines that must be updated. On origin/main the hand-written interface keeps disabledWhen in common with the renamed type, so TS weak-type detection is satisfied at every call site while the declarations reference the source nowhere at all. The consequence is not cosmetic: isBuiltinRowActionVisible would read predicates?.visibleWhen as undefined, hit if (pred == null) return true, and the built-in Edit/Delete gate would silently become "always visible" — an affordance the author gated, shown to everyone, with zero diagnostics anywhere in the package.

RV2 / RV3 — the new DOM pin, red-first against each half

Broken surgically at the trigger decision (hasMenu), not at plan.menuCount, so the planner's return value stays untouched and the break isolates exactly what the pin adds.

ScratchPredictedMeasured
A merely-disabled item no longer keeps the triggerpin red, every pre-existing case green1 failed | 53 passed — only the pin (expect(received).toBeInTheDocument())
BuiltinRowActionItem returns null when disabledpin red plus the pre-existing item-level case2 failed | 52 passed, in two different files, both Unable to find an element by: [data-testid="row-action-builtin-edit"]

That asymmetry is the whole argument for the pin: breaking the counting half is invisible to the rendering half's own test file (all 54 of its cases green except the new pin), and the renamed planner case stayed green through both scratches. Both scratches reverted; tree re-verified green.

The rider: measured unpinned, now pinned

The rule "an item that renders merely disabled still counts toward the menu's non-empty decision" was not observably pinned anywhere on this surface:

  • RowActionMenu.emptyGuard.test.tsx's still counts an item that renders merely DISABLED asserted the planner's return value, and planRowActionMenu never reads disabledWhen — the fixture behaved identically to {}, which is what made it vacuous.
  • RowActionMenu.test.tsx does pin the disabled rendering observably (aria-disabled + click suppressed), but it renders BuiltinRowActionItem directly inside an already-open dropdown — it never goes through the component that decides whether a trigger exists at all.
  • The table-level DOM cases in the guard file used only visibleWhen. Nothing joined the two halves.

Added (keeps the trigger for a row whose only item renders merely DISABLED): a row whose single action is disabledWhen-gated keeps its "⋮", and opening that trigger finds the item present and aria-disabled="true".

Why at RowActionMenu and not through ObjectGrid. This is the level where the two halves actually meet — RowActionMenu is both the caller of the planner and the renderer of the item, the exact structural mirror of #4423's renderTable. The ObjectGrid describe in the same file sits a level above it (column alignment plus async userActions plumbing) and its cases read visibleWhen only, so it did not pin this either; routing the pin through it would have added fixture surface without strengthening the counting-to-rendering join.

The vacuous case is renamed, not deleted: an object with no visibleWhen does not hide the item. That is the verdict it actually decides, it is worth deciding, and the misleading name is gone; its comment now points at where the disabled-counting claim really lives. Its fixture is annotated from the production key (CrudAffordances['editPredicates']) — still necessary, because the planner's parameter remains a deliberate visibleWhen-only subset, so a bare object literal still trips excess-property checking.

Changeset graded minor, not patch — a deviation, made as instructed

The dispatch's default was patch, conditioned on verifying nothing published moves. Something published does move, so it is graded by the objectui#4403 criterion instead. RowActionMenuProps is exported from the package barrel, and its editPredicates / deletePredicates narrow from unknown-valued keys to Expression | ExpressionInput — a consumer passing an unknown-typed value, or a bare boolean, stops type-checking. #4403's rule is explicit that a narrowed exported type is minor even when no runtime consumer breaks and no behavior changes. Never major, per the version-alignment rule.

This is also exactly why PR #4423's twin stayed patch: DataTableSchema's keys were already declared { visibleWhen?: unknown; disabledWhen?: unknown }, so deriving there narrowed nothing. The grades differ because the sources differ, not by feel.

Verification

  • Repo-root vitest packages/plugin-grid/59 files / 560 tests passed. Baseline on the branch point was 59 / 559: exactly +1, the new pin, no file count change.
  • tsc --noEmitandtsc -p tsconfig.test.json for @object-ui/plugin-grid — both exit 0, dependency closure ('@object-ui/plugin-grid^...') built first.
  • eslint on both touched files — exit 0, 0 errors; the 24 warnings are pre-existing no-explicit-any / react-refresh on untouched lines.
  • check:control-bytes (4134 files), check:phantom-deps, check:spec-symbols, check-changeset-presence, check-changeset-no-major — all green.

Scope

Only packages/plugin-grid/** plus the changeset. Nothing in plugin-chatbot (#4424), plugin-dashboard (#4357), fields (#4414) or components (#4417 / #4423) was modified — packages/components was read as the pattern reference only. No runtime code changed. No out-of-scope findings to file.


Generated by Claude Code

…m the spec-owned authoring type (#4429)
RowActionMenu.tsx carried its own `BuiltinRowActionPredicates` interface
(`{ visibleWhen?: unknown; disabledWhen?: unknown }`) read at six declaration
sites, tied to nothing. Measured true source: these predicates do NOT come from
`DataTableSchema`'s twins — `ObjectGrid` resolves the object's `userActions`
through `resolveRowCrudAffordances`, which returns `CrudAffordances`'
`editPredicates` / `deletePredicates`, i.e. the spec-owned `RowCrudPredicates`
(ADR-0103) re-exported by `@object-ui/core`. Each site now derives from that:
per-key `Pick` for the planner's visibility-only parameters, one union alias for
the two consumers that serve both built-ins.
Inherits PR #4423's pattern wholesale, including its rider: the "a merely
disabled item still counts toward the menu" rule was measured unpinned here too,
so it gains a DOM pin where a user meets it (trigger survives, and opening it
finds the item `aria-disabled`), and the planner-level case that claimed to pin
it is renamed to the verdict it actually decides — the planner never reads
`disabledWhen`.
No runtime change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 12, 2026 6:11am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-B7R9OEpn.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)489.20KB108.43KB
core (index.js)2.99KB1.14KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)153.42KB41.19KB
fields (index.js)228.99KB56.82KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.33KB1.20KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)62.01KB17.63KB
plugin-chatbot (index.js)181.17KB43.03KB
plugin-dashboard (index.js)120.75KB31.38KB
plugin-designer (index.js)211.16KB42.76KB
plugin-detail (index.js)239.03KB59.77KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)187.99KB49.92KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)110.21KB26.79KB
plugin-map (index.js)18.05KB5.80KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.99KB10.74KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM 复核 (session session_017Qqyix2QcnpUC9XeYVDzx3), closes #4429.

  • The true-source measurement is why this dispatch carried that guard, and it fired: deriving from DataTableSchema per the card's assumption would have coupled the grid to keys it is never handed; the measured chain to the spec's RowCrudPredicates via CrudAffordances (already imported, no new edge, phantom-deps green) is the honest derivation. The premise-drift handling (one interface at six sites, stated openly against the card's four-bullet table) is the reading-governs standard.
  • RV1's result is the strongest of the family: hand-written = ZERO diagnostics in both projects — the built-in Edit/Delete gate dying fully silently — and the pnpm-hardlink mechanics (9 links into the shared store; in-place edit would corrupt every parallel worktree; new-inode replace-and-restore) is process capture the whole loop inherits from here.
  • The minor self-grading is correct and the contrast is exactly right: RowActionMenuProps is barrel-exported and its keys narrow from unknown to the spec union — fix(i18n): retire the reader-less common.search key from the ten locale packs (#4392) #4403's rule applies — while refactor(components): derive the row-menu predicate parameters from the authoring type (#4354) #4423's twin stayed patch because the table's keys were already unknown-typed. The boolean-off-spec consequence is accepted: contract-first, matching the seam call core already shipped; runtime short-circuit unchanged.
  • The RV2/RV3 asymmetry argument, the degenerate-union honesty, and the pin placement reasoning are all to standard. CI converged.

Flipping ready + arming auto-merge.


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 12, 2026 06:21
@yinlianghui
yinlianghui added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 24bb2deAug 12, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4429-grid-row-menu-derive branch August 12, 2026 06:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude