Skip to content

fix(plugin-grid): select-all-matching replays the host's real query — or abstains — instead of fanning out unfiltered (#4501) - #4510

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4501-bulk-fanout-params
Aug 13, 2026
Merged

fix(plugin-grid): select-all-matching replays the host's real query — or abstains — instead of fanning out unfiltered (#4501)#4510
yinlianghui merged 1 commit into
mainfrom
claude/issue-4501-bulk-fanout-params

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Closes #4501

The hazard

resolveBulkRows re-issues the view's query in 500-record pages so a bulk action receives the whole match set rather than the visible window. The query it replayed came from lastFindParamsRef, whose only writer is ObjectGrid's own data loader.

Under a host that fetches the rows itself — ListView passing data + manualPagination + rowCount, i.e. the console — that loader never runs (if (hasInlineData) return), so the ref is not the query behind the rows on screen: absent, or stale from an earlier own-fetch. Either way the ?? {} default let the fan-out ask the server for the whole object — no $filter, no $orderby, no $search — and hand up to 5000 unmatched records to a destructive executor (onBulkDelete) while the bar read "All N matching records are selected".

Reachability changed with #4464: before it the cross-page banner could never appear on that path. PR #4503 is held in draft behind this guard — landing order is the safety mechanism.

The fix — both halves of the #4501 ruling

1. Host params prop (the contract fix). ListView hoists the params object out of its find call — one object, one query, no second literal free to drift from what was actually asked — records it past the stale-request guard so it is always the query that produced the rows on screen, and hands it down as findParams in the same handoff block as rowCount / page / onPageChange. ObjectGrid reads whichever side owns the fetch: hasInlineData is the loader's own guard, which makes it the precise test for "this grid did not issue the query behind these rows". There is deliberately no grid-side ?? {} fallback — that tolerant-consumer shape is exactly what produced the unfiltered fan-out.

2. The abstain floor (the permanent guard). With no query available for the current data path the escalation is not offered at all — one canOfferSelectAllMatching, consumed by both BulkActionBar sites, so the offer and the thing it promises cannot disagree. A host that forgets findParams loses the affordance rather than silently collecting the whole object. That is what makes the unfiltered fan-out structurally unreachable rather than merely currently-wired-right. The same single source is re-checked at the point of consumption, so the gate and the action cannot drift.

Clause 2 rides along. A changed findParams resets the escalation, mirroring the setSelectAllMatching(false) the internal loader runs next to its own params write. Compared by content, not identity, so a host re-render that rebuilds an equal object does not drop the user's escalation.

The internal-loader path is untouched: with the ref populated the fan-out issues the same params it always did, and the selection.type: 'single' suppression is unchanged.

Red-first

Both source files reverted to origin/main with the tests in place — 7 of 11 red, restored byte-identical afterwards (sha256-verified). The clause-1 fan-out issued, verbatim:

- Expected
+ Received

  {
-   "$filter": [ "status", "=", "active" ],
-   "$orderby": [ { "field": "name", "order": "asc" } ],
-   "$search": "Row",
    "$select": [ "id", "name",
-     "status",
    ],
    "$skip": 0,
    "$top": 500,
  }

$filter, $orderby and $search all absent — the whole-object read. And the floor case found the affordance offered where it must not be:

expected document not to contain element, found < div data-testid="bulk-cross-page-banner" >
    All 10 on this page are selected.
    < button data-testid="bulk-select-all-matching" > Select all 40 matching

(Angle brackets spaced in that excerpt only — GitHub's body sanitizer eats < followed by a letter at rest, code fence included.)

"Select all 40 matching" over a 26-record host window. The 4 that stayed green either side are the negative controls: both internal-loader must-not-change cases, the single-selection suppression, and the equal-params re-render (which must not reset).

Verification

gate result
new tests, fix restored 11 passed (11)
plugin-grid + plugin-list full suites 96 files, 1112 passed
type-check both packages (src + tests) Done
downstream consumer sweep, prefix direction ...@object-ui/plugin-grid / -list 9 packages green (app-shell, console, plugin-designer, plugin-report, plugin-view, console-starter, byo-backend-console, +both)
eslint both packages 0 errors
check:control-bytes OK (4221 files)
changeset:check + presence OK, 2 changesets

Dependency-closure build ran first, so no gate read a stale .d.ts.

Grading

.d.ts measured both ways against origin/main:

  • plugin-grid — one new optional published member on the exported ObjectGridExternalPaginationProps: findParams?: Record< string, unknown > | null. Additive; nothing removed or narrowed. Reverse-verified that it is genuinely typed (a probe assigning a number is rejected TS2322). A new published props member is minor per the precedent the ruling cites — graded accordingly, never major.
  • plugin-list — emitted .d.ts is byte-identical. No public API change, so patch.

Surface

packages/plugin-grid/src/ObjectGrid.tsx, packages/plugin-list/src/ListView.tsx, tests in both packages, two changesets — the mutual-exclusion surface the ruling set, nothing else. BulkActionBar, app-shell's ObjectView and console/** are untouched. Regions are disjoint from PR #4503's; this branch is off origin/main.

Follow-through, not blocking: once #4503 lands, its test file gains the full-path assertion (the host-only path can reach the escalation there, so the count the bar shows becomes assertable too).


Generated by Claude Code

… or abstains — instead of fanning out unfiltered (#4501)

`resolveBulkRows` re-issues the view's query in 500-record pages so a bulk
action receives the whole match set rather than the visible window. The query
it replayed came from `lastFindParamsRef`, whose only writer is ObjectGrid's
own data loader. Under a host that fetches the rows itself — ListView passing
`data` + `manualPagination` + `rowCount`, i.e. the console — that loader never
runs, so the ref was not the query behind the rows on screen: absent, or stale
from an earlier own-fetch. Either way the `?? {}` default let the fan-out ask
the server for the WHOLE OBJECT and hand up to 5000 unmatched records to a
destructive executor while the bar read "All N matching records are selected".

Two halves, per the #4501 ruling:

1. Host params prop. ListView hoists the params object out of its `find` call,
   records it past the stale-request guard, and hands it down as `findParams`
   in the same block as `rowCount` / `page` / `onPageChange`. ObjectGrid reads
   whichever side owns the fetch — `hasInlineData` is the loader's own guard,
   so it is the precise test for "this grid did not issue the query behind
   these rows". No grid-side `?? {}` fallback: that is what produced the bug.

2. Abstain floor. With no query for the current data path the escalation is
   NOT OFFERED — one `canOfferSelectAllMatching` consumed by both
   `BulkActionBar` sites, so the offer and the thing it promises cannot
   disagree, and a host that forgets `findParams` loses the affordance instead
   of silently collecting the whole object.

Clause 2 rides along: a changed `findParams` resets the escalation, mirroring
the `setSelectAllMatching(false)` the internal loader runs next to its own
params write. Compared by content, so a host re-render that rebuilds an equal
object does not drop the user's escalation.

Red-first, measured by reverting both sources to main with the tests in place:
7 of 11 red, the pre-fix fan-out issuing `{$select:['id','name'],$skip:0,$top:500}`
— `$filter`, `$orderby` and `$search` all absent — and the affordance offering
"Select all 40 matching" over a 26-record host window. The internal-loader path
is unchanged and stays green either side.

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 1:52am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-B1MLlX3V.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) 22.10KB 4.37KB
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) 36.76KB 9.60KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
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) 4.91KB 0.87KB
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) 153.79KB 41.35KB
fields (index.js) 230.07KB 57.07KB
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.33KB 1.20KB
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.03KB 59.77KB
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 02:04
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT — step-7 复核 by PM session session_017Qqyix2QcnpUC9XeYVDzx3.

  • The inherited implementation survived a full independent re-derivation (red-first re-run from scratch matched the inherited claim exactly — 7/11 red with the verbatim unfiltered fan-out params: $filter/$orderby/$search all absent at $top:500).
  • Design details accepted as measured-right: params recorded INSIDE the stale-request guard (the wire always carries the query that produced the rows on screen); the floor expressed as ONE derived condition consumed at both offer sites PLUS a refusal at consumption — offer and action cannot drift; the content-signature reset is key-order-insensitive with its negative control honestly labeled as a guard rather than red-first evidence.
  • Grading correct by measurement: plugin-grid MINOR (new published optional prop member, genuinely typed — TS2322 probe), plugin-list patch (byte-identical .d.ts). The registry-indirection type-coupling honesty note is recorded.
  • CI 20/20 green foreground-polled. Surface exactly the 6 inherited files, regions disjoint from held PR fix(plugin-grid): the cross-page select-all banner works under external pagination (#4464) #4503.

Flipping ready + arming auto-merge. Sequencing continues as ruled: PR #4503 flips + arms only after THIS lands in origin/main — the banner never goes live before the guard. #4503's follow-through (its composition test gaining the full-path escalation assertion) rides its rebase.


Generated by Claude Code


Generated by Claude Code

Merged via the queue into main with commit 7ffd616 Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4501-bulk-fanout-params branch August 13, 2026 02:05
yinlianghui pushed a commit that referenced this pull request Aug 13, 2026
…l compose

PR #4510 (#4501's abstain floor) landed on the same two `BulkActionBar` prop
sites this branch rewrites, so both hunks conflicted. Composed rather than
picked: the offer requires #4510's FLOOR (`canOfferSelectAllMatching` — no
escalation without a query to replay) and carries #4503's RESOLVED total
(`resolvedTotalMatching` — the host's `rowCount` on the external path), i.e.

  totalMatching={canOfferSelectAllMatching ? resolvedTotalMatching : undefined}

at both sites. The floor subsumes the `singleSelection ? undefined : …`
suppression the incoming side spelled there — `!singleSelection` is its first
conjunct — so no gating is lost; a host with a real total but no `findParams`
still gets no offer, which is the safety semantics winning the tie.

Follow-through, ruled in both PRs: the composition test gains the full-path
fan-out assertion #4510 made possible. The real ListView issues a real filtered
query, the real grid offers the escalation off the host's `rowCount`, and the
dispatched bulk action's fan-out is asserted to replay ListView's own params
verbatim — measured against what went on the wire, not a hand-written literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants