Skip to content

fix(plugin-grid): skip and diagnose an unusable bulkActionDefs member instead of crashing the selection bar - #8741

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-8730-bulk-action-defs-bare-string
Sep 9, 2026
Merged

fix(plugin-grid): skip and diagnose an unusable bulkActionDefs member instead of crashing the selection bar#8741
os-justin merged 2 commits into
mainfrom
claude/issue-8730-bulk-action-defs-bare-string

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8730

object-grid has two vocabularies for one affordance, and nothing refused a member
written in the wrong one. bulkActions members are bare action NAMES resolved against
objectDef.actions; bulkActionDefs members are full BulkActionDef OBJECTS used as
authored. Both keys are registered type: 'array' with no of, both spec rows are
z.array(z.unknown()), and a JSON view is invisible to tsc.

Writing a bare name into bulkActionDefs did not fail quietly — it crashed the render:

TypeError: Cannot read properties of undefined (reading 'replace')
 at formatActionLabel (packages/plugin-grid/src/components/RowActionMenu.tsx:43:17)
 at BulkActionButton (packages/plugin-grid/src/components/BulkActionBar.tsx:64:21)

The author's first multi-row selection lost the entire selection bar — count, Clear and
every well-formed sibling def with it.

What changed

resolveBulkActions skips any bulkActionDefs member that is not an object carrying a
non-empty string name, and ObjectGrid reports the skip once, by address.

"Usable" is defined by what the renderer reads, not by a fresh opinion about what a
def ought to carry: BulkActionBar uses def.name twice — as the React key of the
button it maps to, and as formatActionLabel's argument when the def declares no
label. Every other BulkActionDef key is optional at the read site, so name is the
whole predicate — and that one test covers the reported bare string plus, identically,
null, a number, {} and { name: '' }.

The guard site. resolveBulkActions's rawAuthored is the single point where the
authored array becomes the list the bar maps over: its defs return flows to
ObjectGrid's bulkActionDefs, to BulkActionBar's actionDefs, to permittedDefs,
to the .map() that reads key={def.name} and def.label ?? formatActionLabel(def.name).
Guarding there means the key and the label are read off the same validated def, and
BulkActionBar keeps reading def.name unconditionally because by that line it can.
There is one predicate with two readers — the filter and the message — not a check
scattered across the render path.

Referential identity is preserved (every before filter): the defs contract
promises the authored array by reference when nothing folds in, and an always-allocating
filter would break that for every clean view.

Not a coercion, deliberately. 'approve' is not lifted into { name: 'approve' }
and resolved the way bulkActions is. That would make the two vocabularies
interchangeable — a product change to what a bulkActionDefs member means (objectui#3002
/ objectui#3139 made them distinct on purpose), not a crash fix.

The diagnostic rides the channel ObjectGrid already owns for "you declared it, the
renderer dropped it" (one console.warn prefixed [ObjectUI] ObjectGrid TOPIC:, a
useEffect keyed on the schema slice — the same shape as the columns diagnostic and the
export-format warning, not a third one beside them):

[ObjectUI] ObjectGrid bulkActionDefs: object-grid (objectName: 'os_invoice') - 1 of 3
authored bulk-action defs cannot be rendered and is skipped (2 still render).
  - bulkActionDefs[0]: the entry is a string ('approve'), not a def object - this key's
    members are full `BulkActionDef` objects, used as authored. Write
    `{ name: 'approve', operation: 'custom' }` here, or move the bare name to
    `bulkActions`, which resolves it against the object's declared actions and promotes
    the match.
  A `bulkActionDefs` member must be an object with a non-empty string `name`: the
  selection bar uses it as the button's React key AND as the source of its label.

Out of scope, by the dispatch ruling: the mirror direction
(bulkActions: [{ name: 'approve' }], stepped over by resolveBulkActions's
typeof name !== 'string' guard) keeps its existing silent skip. Row 9 of the new pin
asserts that silence, so a diagnostic leaking into it would be caught.

Verification — by content

Everything below is quoted from the vitest JSON reporter (never the text reporter: a
TypeError at module scope reads as a suite death, not a failing assertion), run from
the repo root with file paths.

Reproduction, on the base tree (a9bc02996, source blobs proven identical to
HEAD: by git hash-object before the run): 8 of the 10 new pins RED, including the
reported stack verbatim. Three distinct pre-fix failure modes were measured, not one:
'approve' / 42 / {} threw in formatActionLabel; null threw one frame earlier at
key={def.name} (Cannot read properties of null (reading 'name')); and { name: '' }
did not throw at all — it rendered a nameless, unlabelled button
(expected [ 'bulk-action-' ] to deeply equal []).

The discriminating axis. "Skip the bad member" is also satisfied by an implementation
that skips everything, so both caricatures were run as mutations of the committed fix,
each with an EXIT INT TERM trap, each proven to have reached disk (deleted-anchor
count 0, injected count 1, blob differs from HEAD:) and each restored with
git checkout HEAD -- ABSOLUTE_PATH proven by an empty git diff HEAD and a matching
git hash-object:

pin isUsable... forced false (skip everything) forced true (skip nothing)
1-5 the unusable class, 0 buttons green RED (the crash returns)
6 mixed list renders exactly the 2 good defs, in order RED (expected [] to equal [ 'bulk-action-archive', … ]) RED
7 clean list untouched + defs by reference RED green
8 diagnostic fires by name / does not fire when clean RED (no-fire leg: expected [ Array(1) ] to equal []) RED (fire leg)
9 the two directions still fail differently green RED
10 a name in the RIGHT key still promotes green green

Rows 6, 7 and 8's no-fire leg are what "skip everything" cannot pass; rows 1-5, 8's fire
leg and 9 are what "skip nothing" cannot pass. Rows 7 and 10 are the untouched-behaviour
anchors, and are green under "skip nothing" precisely because that mutation is the base
behaviour — its red set is byte-identical to the base-tree red set, which is an
independent check that the mutation reproduced the defect rather than a new one.

Green (all at b9b0abeab, a clean tree):

  • new pins: 10 tests, 10 passed, 0 failed
  • packages/plugin-grid/: 347 files, 1055 tests, 1055 passed, 0 failed
  • packages/plugin-view/ packages/plugin-designer/ packages/plugin-list/ examples/schema-catalog/: 546 files, 3488 tests, 3488 passed, 0 failed
  • packages/app-shell/: 2156 files, 6406 tests, 6405 passed, 0 failed (1 skipped)
  • turbo run type-check --filter=@object-ui/plugin-grid (builds the dependency closure
    first): exit 0. It earned its place — it caught four TS7006s in the new pins that
    vitest was green through; fixed in the second commit.
  • node scripts/check-control-bytes.mjs: OK (scanned 6922 tracked text file(s)); plus a
    direct grep -naP control-byte scan of the four changed files, zero hits.
  • node scripts/check-changeset-presence.mjs: exit 0, declares the changeset.
  • node scripts/check-governed-queue-guard.mjs --test on all four paths:
    NOT GOVERNED - 4 path(s) checked against 5 governed surface(s); none matched.

Lint, narrowed and declared. eslint packages/plugin-grid --format json — exit 0,
155 files linted (the count read from the JSON output, the population from eslint's
own flat config, not from a guess), 0 errors. The 99 files carrying warnings are
pre-existing no-explicit-any / react-refresh noise; the three warnings on the new pin
file are the as any schema casts every sibling grid test in this package carries. The
rest of the farm is CI's: this repo's root lint is turbo run lint per package.
The narrowing is safe to state because this config is not type-aware — no
projectService and no parserOptions.project anywhere in eslint.config.js — so this
diff cannot move the verdict on any file it did not touch.

NOT MEASURED, deliberately: check:doc-snippets, check:doc-examples,
check:readme-exports and check:eager-closure were not run. They need a fully built
tree and resolveBulkActions is not exported from packages/plugin-grid/src/index.tsx,
so the two new exports are package-internal and reach no published surface. CI owns them.

A premise in the card that measurement contradicted

The dispatch says packages/plugin-grid/src/__tests__/bulkActionMembers-8071.test.tsx
"currently asserts the render rejects with that exact TypeError" and must be rewritten
in this same commit. That file is not on main. It exists only on the unmerged
branch origin/claude/issue-8071-member-pins-near-miss-four (commit e4b19de6f), which
git merge-base --is-ancestor reports is not an ancestor of main — a reading confirmed
against a shallow-clone false negative two ways: git rev-parse --is-shallow-repository answers false, and a control commit known to be in this
history answers exit 0 on the same check.

So there is nothing here to rewrite, and creating that path on this branch would only
manufacture an add/add conflict with the other PR. Instead:

  • What it protected survives as row 9 of the new pin file — that direction two is
    not direction one's silent drop. Before this change that distinction was
    crash-vs-silence; it is now diagnostic-vs-silence, and both legs are asserted together
    so they cannot drift apart.
  • Its row 5 will go red whichever of the two PRs lands second, and must be rewritten
    there. That is visible rather than silent, but it is a real cross-PR interaction and
    should not be discovered by the merge queue.

Its row 2 was also the measurement behind leaving direction one alone: it asserts
expect(warn).not.toHaveBeenCalled() for bulkActions: [{ name: 'approve' }], i.e. an
in-flight pin does assert the current silence. Nothing on main asserts it either way.
Direction one is therefore untouched, as the ruling ordered, and row 9 now pins that
silence on main too.


Generated by Claude Code

…er instead of crashing the selection bar (objectui#8730)

`bulkActions` and `bulkActionDefs` are one affordance in two vocabularies —
bare action NAMES resolved against `objectDef.actions`, and full
`BulkActionDef` OBJECTS used as authored — and nothing refused a member
written in the other one: both keys are registered `type: 'array'` with no
`of`, both spec rows are `z.array(z.unknown())`, and a JSON view is invisible
to `tsc`.

Writing a bare name into `bulkActionDefs` crashed the render. The string
travelled into the authored list untouched, `BulkActionBar` rendered a button
for it, and `def.label ?? formatActionLabel(def.name)` threw `TypeError:
Cannot read properties of undefined (reading 'replace')` — so the author's
first multi-row selection lost the whole selection bar, including its count,
its Clear button and every well-formed sibling def. `key={def.name}` was
`undefined` as well.

`resolveBulkActions` now skips any member that is not an object carrying a
non-empty string `name`. "Usable" is defined by what the renderer reads, not
by a fresh opinion: `name` is both the React `key` and `formatActionLabel`'s
argument, so one test covers the reported bare string and, identically,
`null`, a number, `{}` and `{ name: '' }`. The guard sits at the single point
where the authored array becomes the list the bar maps over, so the key and
the label are read off the same validated def, and the clean case still
returns the authored array by reference.

The skip is not silent: `ObjectGrid` reports it once per authored array
through the channel it already owns for "you declared it, the renderer
dropped it", naming the block, the index, what was seen and what to write
instead — including that a bare name belongs in `bulkActions`.

Not a coercion, deliberately: `'approve'` is not lifted into `{ name:
'approve' }` and resolved the way `bulkActions` is. That would make the two
vocabularies interchangeable, which is a product change to what a
`bulkActionDefs` member means (objectui#3002 / objectui#3139), not a crash
fix. The mirror direction (`bulkActions: [{ name: 'approve' }]`) keeps its
existing silent skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…pins

`ReturnType<typeof vi.spyOn>` erases the call-log element type, so both
helpers took `args` implicitly `any` and `tsc -p tsconfig.test.json` refused
the file with four TS7006s. The package's `type-check` is what caught it — the
file was green under vitest either way, which is exactly the gap between "the
tests pass" and "the tests are checked".

A local structural `ConsoleSpy` reads only what these helpers use (the call
log) and stays true for both `console.warn` and `console.error` spies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3483.4 KB 3512.7 KB
Main entry chunk (gzip) 144.1 KB 350 KB
Entry file index-GVevTVWL.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
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.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 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.68KB 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) 499.42KB 114.32KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 244.96KB 61.76KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 252.39KB 65.32KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 134.20KB 33.48KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 210.67KB 57.19KB
plugin-kanban (index.js) 55.50KB 15.75KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

Copy link
Copy Markdown
Collaborator Author

Contract review — accepted, flipped out of draft, auto-merge armed. 25 of 33 green with shards, Type Check and Lint still running; nothing failed.

First, a correction that is mine. My dispatch instructed you to rewrite bulkActionMembers-8071.test.tsx "in the same commit". That file does not exist on main — I took the card's claim as fact and passed it through as an instruction without checking. I have since verified it independently: git cat-file -e origin/main:packages/plugin-grid/src/__tests__/bulkActionMembers-8071.test.tsx fails, and the file lives only on claude/issue-8071-member-pins-near-miss-four.

The way you established that is the part worth keeping: an --is-ancestor exit-1 is a false negative on a shallow checkout, so you controlled it — --is-shallow-repository = false, plus a commit known to be in this history answering exit 0 on the same check. A negative reading with a lit control, which is exactly what the zero-hit rule asks for. And declining to create the file on this branch was right: it would have manufactured an add/add conflict with the sibling PR — the same conflict shape I spent this session resolving on #8719.

Three failure modes, not one. The card implies a single crash; you measured three:

member what actually happens
'approve' / 42 / {} throws in formatActionLabel — the reported stack
null throws one frame earlier, at key={def.name} (BulkActionBar.tsx:221)
{ name: '' } ⚠️ does not throw at all — it rendered a nameless, unlabelled button

So for the last one this is a behaviour change rather than a crash removal, and saying that plainly is better than letting "fixes the crash" quietly cover it.

The guard site is the right one and the reasoning is checkable. rawAuthored in resolveBulkActions.ts is where the authored array becomes the list the renderer maps over, so the key and the label read the same validated def and BulkActionBar keeps reading def.name unconditionally because by that line it can. One predicate, two readers — the filter and the message — so the check is not scattered. And every before filter preserves reference identity for a clean array, which resolveBulkActions.test.ts already pins; an always-allocating filter would have reddened it. That is a non-regression you inherited rather than wrote, and you noticed it.

The discriminating pair is exactly what the ruling asked for, and the cross-check is better than the legs. "Skip everything" reddens 6/7/8 while 1–5 stay green — which is why rows 6, 7 and 8 had to exist, since the unusable-class rows alone are passed by an implementation that skips everything. "Skip nothing" reddens 1,2,3,4,5,6,8,9. And that mutation's red set is byte-identical to the base-tree red set — independent evidence that the mutation reproduced the reported defect rather than a new one. I did not ask for that check; it is the strongest line in the report.

The diagnostic is an assertion, both ways. It fires naming the block, the index, the member and the other key where a bare name belongs; it does not fire for a clean list; and the no-fire leg reddens under "skip everything", so "a warning that fires always" is refused by measurement rather than by intent. Routing it through the channel ObjectGrid already owns for "you declared it, the renderer dropped it" — rather than inventing a third — is the right call.

Direction one stayed out, with the measurement that settles it: nothing on main asserts the current silence either way, but the in-flight #8071 pin's row 2 asserts expect(warn).not.toHaveBeenCalled() for it. Row 9 now pins that silence on main too, so a later diagnostic leaking into direction one is caught here rather than only on a branch.

⚠️ The collision is real and I am handling it, not you. PR #8737 carries the #8071 pin, and its row 5 asserts the crash this PR removes. Whichever lands second goes red there. I am flagging it on #8737 with the exact row and the rewrite it needs; the merge queue's speculative build protects main either way, so the cost is a queue cycle, not a breakage.

Noted and agreed on not filing: BulkActionBar being publicly exported means a host that hand-builds actionDefs bypasses this guard — but that is a TS-typed React prop, checked at the type level, and a different surface from authored metadata. Reporting it rather than filing it was the right line.


Generated by Claude Code

@os-justin
os-justin added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit baf3776 Sep 9, 2026
35 checks passed
@os-justin
os-justin deleted the claude/issue-8730-bulk-action-defs-bare-string branch September 9, 2026 03:16
os-warren pushed a commit that referenced this pull request Sep 9, 2026
…ose contract that landed

Row 5 pinned objectui#8730's crash as the CURRENT shape, with the issue
filed from it, so that landing the fix would RED the row rather than leave
it describing nothing. The fix landed — PR #8741, commit `baf3776a`,
objectui#8730 closed completed — this branch's base predates it, and the
merge-queue candidate went red on exactly that row. This is the rewrite the
row was built to force.

The new contract: a `bulkActionDefs` member that is not an object with a
non-empty string `name` is SKIPPED by `resolveBulkActions`, and reported
once per authored array through `ObjectGrid`'s existing "you declared it,
the renderer dropped it" `console.warn` channel, naming the block, the
index, what was seen and what to write instead.

The row keeps its discriminating job — a bare name is not this key's
vocabulary — and asserts three things a bare "does not throw" would not:

  - SKIPPED, not coerced. `'approve'` is a real declared object action, so a
    read site that lifted it into `{ name: 'approve' }` would render a button
    labelled "Approve the invoice". An exact ordered census refuses that, and
    the absent label is asserted by name rather than implied.
  - EXACTLY ONE MEMBER WIDE. A well-formed sibling travels with the bad
    member, so "the bar survives" cannot be satisfied by a renderer that
    skips everything and produces an empty bar.
  - DIAGNOSED. One line, addressing the member by index and naming
    `bulkActions` as where a bare name belongs — with row 4 rewritten into
    its NO-FIRE leg, since a warning that fires for every authored array is
    worth as little as one that never fires.

Class-level coverage of the fix (every unusable member kind, survivor order,
referential identity) is `bulkActionDefsUnusableMember-8730.test.tsx`; this
file keeps the member-VOCABULARY half it was written for. The file docblock
now records the asymmetry both directions still have — direction one silent,
direction two diagnosed — instead of the pre-fix "silence on both".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 9, 2026
… exportOptions / bulkActions / bulkActionDefs (objectui#8071 slice 1) (objectstack-ai#8737)

* test(spec-parity): pin the member shapes of object-form.fields and object-grid's three action/export keys

objectui#8071's first declared slice: convert four of the 62 exempted
array/object-armed inputs into named per-block member pins, delete their
exemptions and lower the ratchet with them (62 -> 58).

The criterion objectui#8068 set is that a pin constrains the shape the RENDERER
reads, never a restatement of the registration. Measured per key:

- object-form.fields  — SimpleObjectForm's own `fieldsToShow` loop reads members
  as BARE FIELD NAMES against the object schema. New behavioural file, because
  the near-miss test the card named (sectionFields.spec-parity.test.ts) pins the
  neighbouring `sections[].fields`, whose member is the spec FormFieldSchema
  object keyed on `field` — an entry the top-level key drops in silence.
- object-grid.bulkActions / .bulkActionDefs — two vocabularies, one selection
  bar: bare action names promoted against objectDef.actions, versus full
  BulkActionDefs left as authored. Both cross-substitutions are pinned.
- object-grid.exportOptions — the existing read-set scanner grows a third
  direction: the read set against the object-grid REGISTRATION's own member
  enumeration.

Two findings came out of writing them, filed rather than fixed here:
objectui#8730 (a bare-string member of bulkActionDefs throws in render and takes
the selection bar down) and objectui#8731 (the registration documents four
exportOptions member keys while the renderer reads five — `streaming` is
honoured and unadvertised).

Refs objectstack-ai#8071 (object-form.fields, object-grid.exportOptions, object-grid.bulkActions, object-grid.bulkActionDefs)

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

* test(plugin-form): drop the unused `screen` import the object-ui/no-unused-imports rule refused

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

* test(plugin-grid): move objectui#8071's row 5 onto the skip-and-diagnose contract that landed

Row 5 pinned objectui#8730's crash as the CURRENT shape, with the issue
filed from it, so that landing the fix would RED the row rather than leave
it describing nothing. The fix landed — PR objectstack-ai#8741, commit `baf3776a`,
objectui#8730 closed completed — this branch's base predates it, and the
merge-queue candidate went red on exactly that row. This is the rewrite the
row was built to force.

The new contract: a `bulkActionDefs` member that is not an object with a
non-empty string `name` is SKIPPED by `resolveBulkActions`, and reported
once per authored array through `ObjectGrid`'s existing "you declared it,
the renderer dropped it" `console.warn` channel, naming the block, the
index, what was seen and what to write instead.

The row keeps its discriminating job — a bare name is not this key's
vocabulary — and asserts three things a bare "does not throw" would not:

  - SKIPPED, not coerced. `'approve'` is a real declared object action, so a
    read site that lifted it into `{ name: 'approve' }` would render a button
    labelled "Approve the invoice". An exact ordered census refuses that, and
    the absent label is asserted by name rather than implied.
  - EXACTLY ONE MEMBER WIDE. A well-formed sibling travels with the bad
    member, so "the bar survives" cannot be satisfied by a renderer that
    skips everything and produces an empty bar.
  - DIAGNOSED. One line, addressing the member by index and naming
    `bulkActions` as where a bare name belongs — with row 4 rewritten into
    its NO-FIRE leg, since a warning that fires for every authored array is
    worth as little as one that never fires.

Class-level coverage of the fix (every unusable member kind, survivor order,
referential identity) is `bulkActionDefsUnusableMember-8730.test.tsx`; this
file keeps the member-VOCABULARY half it was written for. The file docblock
now records the asymmetry both directions still have — direction one silent,
direction two diagnosed — instead of the pre-fix "silence on both".

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

* test(plugin-grid): name row 1 as the reachability control for row 5's absent-label assertion

`expect(queryByText('Approve the invoice')).not.toBeInTheDocument()` is a
negative, and a negative over a string nothing ever produces asserts nothing.
Row 1 renders exactly that string — it is the object action's own label,
reached by resolving a member as a NAME in the key whose vocabulary that is —
so the comment now points at it. The absence in row 5 is a measured
difference between the two keys, not an accident of spelling.

Comment only; no assertion changed.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-grid): a bare-string member in object-grid.bulkActionDefs throws during render and takes the whole selection bar down

2 participants