Skip to content

fix(app-shell): the console record header honors userActions predicates (#4213) - #4524

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4213-record-header-predicates
Aug 13, 2026
Merged

fix(app-shell): the console record header honors userActions predicates (#4213)#4524
yinlianghui merged 1 commit into
mainfrom
claude/issue-4213-record-header-predicates

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4213

The asymmetry

userActions.edit / .delete reached the console record page's header in their
boolean form and only in that form. The boolean switch flows through
resolveRecordHeaderActionGatesresolveEffectiveCrudAffordances, so
userActions: { delete: false } hid Delete on the list row and on the record
header — which is exactly the 定位线索 the card's author reported. The per-record
object form (edit: { visibleWhen: … }, objectui#2614) reached the list row
alone: synthSystemActions at RecordDetailView.tsx:1911 gated on

edit: objectAffordances.edit && recordWriteAllowed,
delete: objectAffordances.delete && recordDeleteAllowed,

— the affordance/permission channel — and nothing on that path ever parsed a
predicate, let alone evaluated one. An author narrowing "who may edit this
object" to "which records may be edited" therefore kept the converged list and
lost the record page, where Edit still opened a form the server rejects on save.

Three surfaces, one evaluator

This is the last of three sites, and it closes the convergence:

surface header served by predicate before after
row kebab plugin-grid isBuiltinRowActionVisible (#2614) consumed unchanged
DetailView header plugin-detail (#4419 / PR #4515) consumed since #4515 unchanged
console record page app-shell RecordDetailView.synthSystemActions not consumed consumed

Same helper family as the other two, so one authored predicate gets one answer
platform-wide: userActionPredicates from @object-ui/core for the parse, and
useRowPredicate from @object-ui/react for the evaluation, with
{ fallback: false, warnOnError: true, label, fields }.

Fold site and the hook-rules answer. synthSystemActions is a plain IIFE in
the component body, sitting after the isLoading / !objectDef /
pageRecordStatus === 'missing' early returns — hooks cannot live inside it. So
the parse and the four evaluations sit at component level beside
recordWriteAllowed / recordDeleteAllowed, the record-level verdicts they
join, and their results are threaded into the IIFE as ordinary values. That is
the same shape PR #4515 used.

Semantics

  • visibleWhen false ⇒ the synthesized action is not emitted. Fails closed,
    and counts as declared by != null rather than by truthiness, so a literal
    visibleWhen: false hides rather than reading as ungated (the objectui#3492
    invariant). The canonical { dialect, source } envelope is accepted.
  • disabledWhen true ⇒ the entry renders disabled. On sys_edit this
    composes with OR onto the disabled key the approval lock already used
    (framework#3794) rather than opening a second one: an approval lock and a
    declared predicate are independent reasons for the same off, and neither may
    cancel the other. On sys_delete — which declared no disabled key before —
    it is spread only when it holds, so the emitted ActionDef is byte-identical
    when no predicate is declared. Fails soft, with the != null gate outside the
    evaluation so disabledWhen: '' reads as "no condition".
  • The existing gates remain independent conjuncts, never replaced. The
    predicate only ever subtracts: it can never resurrect a button the lifecycle
    bucket closed or the user may not press.

Red-first

New file RecordDetailView.userActionPredicates.test.tsx mounts the real
console record page (MemoryRouter + MetadataCtx, the harness
RecordDetailView.headerRefresh.test.tsx established) and asserts on real header
DOM. Reverse-verified by taking the fix back out with a sha256-verified patch —
never git stash:

# fix removed (git checkout origin/main -- RecordDetailView.tsx)
Tests  10 failed | 11 passed (21)
# fix restored
Tests  21 passed (21)

The card's own repro, verbatim, on a status: 'reported' record whose
edit.visibleWhen is record.status == "pending" || record.status == "in_progress":

× hides the header Edit on a record its `edit.visibleWhen` excludes
AssertionError: expected [button](1) to be null
+ Received:
  button class="… bg-primary …"
    span
      Edit

The 10 that go red without the fix: both visibleWhen hides (edit, delete), both
disabledWhen greys, the literal visibleWhen: false gate, the
{ dialect, source } envelope, the fail-closed fault, the CRUD-only subtraction,
and two of the approvalLocked composition cases.

Must-not-change — green on both sides (the 11)

  • boolean delete: false / edit: false still hide (the card's 定位线索 — the
    affordance resolver stays their only channel; a bare boolean yields no
    predicate);
  • predicate-true records keep their buttons, enabled;
  • an object with no userActions is completely ungated;
  • an engine-owned bucket still closes both, however loudly the predicate holds;
  • sys_share and other non-CRUD synth actions untouched;
  • approvalLocked disables Edit with no predicate declared, and still does when
    a declared predicate does not hold — the pre-[console] 记录详情页头不消费 userActions.<action>.visibleWhen:同一谓词列表行已生效、记录页头照旧渲染按钮 #4213 behavior byte-identical;
  • disabledWhen fails soft on a fault, and an empty disabledWhen is no
    condition.

Verification

  • npx vitest run packages/app-shell366 files, 3542 passed, 1 skipped, 0 failed
  • npx tsc --noEmit and npx tsc -p tsconfig.test.json (app-shell) → both exit 0
  • ESLint on the changed files → 0 errors; warning count on RecordDetailView.tsx
    is 116 before, 116 after (zero new findings)
  • node scripts/check-control-bytes.mjs → OK, plus a direct control-byte
    self-scan of all three changed files → clean
  • .d.ts measured both ways: built app-shell with and without the fix and
    diffed the emitted dist0 .d.ts differences across 415 declaration
    files. No public type surface moves, which is what makes this a patch.

Scope

packages/app-shell/src/views/RecordDetailView.tsx + one test file + one
changeset. Untouched: selection bar (#4420), DetailView / RelatedList (landed
in #4515), console/AppContent.tsx (#4252), metadata-admin (#4446),
content/docs/releases/.

One extension beyond the ruling's literal text, reported rather than made
silently.
The ruling named synthSystemActions. The InlineEditProvider
canEdit expression at the former :2082 mirrors that same gate conjunction
(resolveRecordHeaderActionGates(...).edit && recordWriteAllowed && !approvalLocked) because the header CTA and the record body's pencils are one
edit affordance in two places. Folding the predicate into only one of them would
have re-created, inside a single file, precisely the asymmetry this card is
about — Edit gone from the header while a double-click still opened a draft the
object says this record may not have. The predicate therefore joins that
conjunction too, exactly where approvalLocked already sits, and disabledWhen
suppresses the pencils for the same reason the approval lock does.


Generated by Claude Code

…es (#4213)

`userActions.edit` / `.delete` reached the console record page's header in
their boolean form but not in their predicate form. The boolean switch flows
through `resolveRecordHeaderActionGates` -> `resolveEffectiveCrudAffordances`,
so `userActions: { delete: false }` hid Delete on the list row and the record
header alike. The per-record object form — `edit: { visibleWhen: ... }` —
reached the list row only: `synthSystemActions` gated on
`objectAffordances.edit && recordWriteAllowed` and never parsed, let alone
evaluated, the predicate.

Fold the predicates in as a fourth conjunct, through the same helper family
the row surfaces use — `userActionPredicates` from `@object-ui/core` for the
parse, `useRowPredicate` from `@object-ui/react` for the evaluation. The hooks
live beside the other record-level verdicts because `synthSystemActions` is a
plain IIFE sitting after the component's early returns; their results are
threaded in as ordinary values.

`visibleWhen` false hides the synthesized action (fails closed; a literal
`false` counts as a declared gate). `disabledWhen` true composes with OR onto
the `disabled` key `sys_edit` already used for the approval lock, and spreads
a fresh one onto `sys_delete` only when it holds. The record body's
inline-edit gate joins the same conjunction where `approvalLocked` already
sits, so the header CTA and the body pencils cannot disagree.

The affordance, permission and record-writability gates are untouched — the
predicate only ever subtracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 4:06am

Request Review

@github-actions github-actions Bot added the tests label Aug 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-CA3wd9iI.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 156.23KB 42.29KB
fields (index.js) 230.14KB 57.12KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.07KB 17.65KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.40KB 50.10KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 04:17
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

PM step-7 复核 — ACCEPT (session_017Qqyix2QcnpUC9XeYVDzx3)

  • Fold site measured, not guessed: userActionPredicates + useRowPredicate read once at the component level (~:994) and threaded into the synthSystemActions IIFE at :1911 — answers the hook-rules question the card left open.
  • Red-first 10/21 pre-fix, including the card's verbatim repro (AssertionError: expected the Edit button to be null, on status 'reported'); reverse verification held.
  • approvalLocked composes as OR — disabled: approvalLocked || editDisabledByPredicate — with the four-cell approvalLocked × predicate truth table pinned.
  • The declared extension (folding editVisible / !editDisabledByPredicate into the InlineEditProvider canEdit conjunction at the former :2082) is accepted as within the ruling's "measure the precedent at the same site and compose": the measured approvalLocked precedent already spans both places, and reverting only the header would recreate the card's asymmetry inside one file. The offered 5-line revert is declined.
  • .d.ts byte-identical → patch grade correct. CI 20/20 green.
  • This completes the three-surface convergence: row kebab, DetailView header (fix(plugin-detail): the record detail header honors userActions predicates (#4419) #4515), and the console record header (this PR) now all honor userActions predicates through the one evaluator.

Auto-merge armed (squash).


Generated by Claude Code


Generated by Claude Code

Merged via the queue into main with commit af52932 Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4213-record-header-predicates branch August 13, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[console] 记录详情页头不消费 userActions.<action>.visibleWhen:同一谓词列表行已生效、记录页头照旧渲染按钮

2 participants