feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page - #7226

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure
Sep 1, 2026
Merged

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page#7226
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #7189 — the disclosure half only.

A grouped grid now states, where the group counts are, that it grouped a page.

The card's numbers were stale, so here is today's main

objectui#7179 landed as a6d8b8d44 after the card was written, and it changed
what the card measures: the projection now carries the grouping field, so the
groups resolve and the (empty) collapse is gone. The card's table (four
groups for five units, 33 / 3 / 46 / 18) cannot be reproduced and nothing
here was built against it.

Re-measured on a6d8b8d44 — 186 records over five business units sized
86 / 61 / 31 / 7 / 1, $top: 100, $select confirmed to carry
business_unit:

fixture orderinggroup headers renderedcountsstore holds
contiguous2 of 586, 1486, 61, 31, 7, 1
interleaved5 of 531, 31, 30, 1, 786, 61, 31, 7, 1

In both, the document contained no 186, no "partial", no "loaded". Which
shape you see is a property of row ordering, not of the tree — the defect is
the same one either way, and the disclosure has to be true of both.

The paging footer the card quotes is not the grid's: Showing first N records. More data may be available. lives in
packages/plugin-list/src/ListView.tsx (list.dataLimitReached), a different
component in a different package. A standalone grouped grid renders no footer
at all.

What this adds

useGroupedData's grouping and aggregation algorithm is untouched — it is
correct for what it does, and the counts above are still page slices
afterwards. What was missing is any statement that client-side grouping is
what you are looking at.

  • a short Partial marker beside every group count, at every nesting
    depth
    , carrying the whole sentence as its title and its accessible name;
  • one line directly above the group list, inside the grouped region rather
    than in the paging footer, which is not a statement about what was grouped.

Is a result-set total reachable where the group headers render? Yes

resolvedTotalMatching (ObjectGrid.tsx:3769) is in scope at the grouped render
site (~4688 / ~4755), and it is the same ONE derived value the pager and both
bulk-bar sites already read — deliberately not re-spelled here, per the note
objectui#4464 left on it. It resolves from the grid's own result.total when
the grid owns the fetch, and from a host's rowCount when a host does. Both
paths are pinned.

So the strong wording is supported, and the trigger never outruns what the
component knows:

conditionwording
total known and greater than rows in hand"Grouped over the first 100 of 186 records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here."
no total, window came back full"Grouped over the 100 records loaded. More may match this view…" — may only say may, the same inference plugin-list's own footer draws when no total is known
rows handed in inlinenothing — nothing was asked for and nothing was withheld
result set fits the pagenothing

That last row is the point of the whole thing: a marker that is always on says
nothing.

Deliberately NOT here

Server-side grouping. It is the card's own preferred durable fix, an
API-surface decision open on #5560, and out of scope for this branch: nothing
builds toward it, no flag anticipates it, the fetch is unchanged. That half of
#7189 stays open.

No metadata contract was widened

No schema key was added — the condition is derived from data the grid already
holds. GroupRow (exported from this package) gains two optional props,
partialLabel and partialTitle; @objectstack/spec is untouched.

Tests — 13 new pins, and an ablation in both directions

packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx.
Prediction was written before either leg ran and both matched exactly.

legmutationpredictedobserved
AgroupingIsPartial = false (disclosure removed)8 failed / 5 passed8 failed / 5 passed
BgroupingIsPartial = isGrouped (marker unconditional)3 failed / 10 passed3 failed / 10 passed

Leg B goes red on the controls only — fits-in-one-page, inline rows,
short window — which is what makes the eight positive pins worth anything.

Each leg: mutation proved on disk by injected-marker line count (1) and
removed-expression line count (0) and a blob hash moved off
HEAD:packages/plugin-grid/src/ObjectGrid.tsx
(eaab121121273aea4e6304d96d82f6fa0da6b06d); restore by
git checkout HEAD -- ABSOLUTE_PATH and proved by stategit diff HEAD,
git diff --cached and git status --short all empty and the blob hash back
to HEAD's — with the restore trapped on EXIT INT TERM. No rebuild was needed
or performed: vitest.config.mts:288 aliases @object-ui/plugin-grid to
packages/plugin-grid/src, verified by reading the alias table.

jsdom caveat, closed rather than assumed. jsdom applies CSS media-query
rules irrespective of innerWidth, so a width-dependent reading in it is not a
measurement. This disclosure is not width-dependent: it carries no responsive
visibility utility, and grouping never reaches the width-driven branch —
if (useCardView && data.length > 0 && !isGrouped). The last pin asserts
that gate in source rather than trusting the reading.

Verification, all at 1d8fd9e65 (the final commit)

  • pnpm exec vitest run packages/i18n/ packages/plugin-grid/166 files,
    1912 tests, all passed
    . Includes all-locales-key-parity, which owns the
    ten-pack key sets the three new grid.grouping.* strings had to satisfy.
  • turbo run type-check --filter @object-ui/plugin-grid --filter @object-ui/i18n — 15/15 tasks. tsc -p tsconfig.test.json --listFiles
    confirms the new test file and both edited sources are in that program, so
    "typecheck clean" actually covers them.
  • Consumer sweep, direction dependents of @object-ui/plugin-grid (named
    explicitly, because the ... prefix means opposite things in pnpm and
    turbo): app-shell, console, plugin-designer, plugin-report, plugin-view,
    plugin-list — turbo run type-check, 41/41 tasks.
  • check:i18n-keys, check:i18n-drift (0 en values changed, 3 keys added),
    check:control-bytes, check:doc-types, check:doc-fences,
    check:changeset-presence — all exit 0 at 1d8fd9e65.
  • eslint, plain form, on all 13 changed .ts/.tsx files: 0 errors; the
    new test file is 0 errors / 0 warnings. Type-aware linting is not enabled in
    eslint.config.js (no parserOptions.project), so this diff cannot move a
    verdict in a file it did not touch — the narrowing is a measurement, not a
    skip. The repo-wide scan is CI's.
  • NOT MEASURED locally, declared:check:doc-snippets and
    check:readme-exports. Both exit on an explicit precondition — 21
    packages unbuilt in a fresh worktree — and both judge only fenced code
    blocks. This diff adds zero fenced blocks to any document (measured:
    zero added lines carrying a fence). CI builds and runs them.

Changeset

@object-ui/plugin-grid and @object-ui/i18n, both minor. The docs edits
ride along in the same PR, so no separate empty-frontmatter changeset is owed —
that form is for a docs-only change, where a patch would falsely claim a
released package moved.


Generated by Claude Code

… it grouped a page
`useGroupedData` buckets the rows the browser already holds and computes every
per-group aggregate from that same array, so both the set of groups and every
number in a group header are properties of the fetched page, not of the query.
The algorithm is correct for what it does and is untouched here; what was
missing is any statement that client-side grouping is what you are looking at.
Measured on today's `main` (a6d8b8d) over a 186-record store distributed
86/61/31/7/1 across five business units with a 100-row page: contiguous rows
rendered TWO group headers (86, 14) with three units absent from the screen
entirely; interleaved rows rendered all five with every count a page slice
(31/31/30/1/7). The document contained no "186", no "partial", no "loaded".
The disclosure goes where the authoritative-looking number is: a short
`Partial` marker beside every group count at every depth, carrying the whole
sentence as its title and accessible name, plus one line directly above the
group list inside the grouped region — not in the paging footer, which is not
a statement about what was grouped.
The trigger never outruns what the component knows. A real match total
(`resolvedTotalMatching`, the one derived value the pager and both bulk-bar
sites already read) states the fact with both numbers; a full window with no
total may only say "more may match", the same inference plugin-list's own
footer draws. Inline rows are not a page and are never marked, and a grouped
grid whose result set fits in one page shows nothing at all.
Server-side grouping is deliberately out of scope — an API-surface decision
still open on objectui#5560. Nothing here changes the fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Removes the four `no-explicit-any` warnings the new file carried; the row
shape is a real interface and the find params read through
`Record<string, unknown>`. No assertion changed — still 13 passed (13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation plugin tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-BsrOgisl.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)208.80KB56.56KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.47KB27.66KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM

Verified over git: clean against origin/main5015fcf52 (merge-tree --write-tree exit 0), changeset present, 17 files, all ten locale packs.

My assumption was wrong again, and this one changes the card's framing

The order said the footer already discloses paging and the problem is only where it says so. False:

the footer the card cites is NOT the grid's — Showing first N records. More data may be available. lives in plugin-list/src/ListView.tsx as list.dataLimitReached, a different component in a different package, and a standalone grouped grid renders no footer at all.

So the card's premise — "the footer does disclose it, but the group header is the number people trust" — understates the defect. In the standalone case there was no disclosure anywhere, not a badly-placed one. Fourth PM premise falsified by a lane this shift.

The conclusion survived the falsification, which is the useful part: a total is reachable at the group-header render site via resolvedTotalMatching, so the strong "first 100 of 186" wording is supported rather than assumed.

Re-measuring rather than reproducing the stale table was the right call

The order warned the card's figures predate #7179. What came back is sharper than "the numbers changed":

two group headers (86, 14) with three units absent when rows were contiguous; five headers reading 31/31/30/1/7 when interleaved — which shape appears is a property of row ordering, not of the tree

⇒ Neither the card's 33/3/46/18 nor this lane's numbers are "the" numbers. That is worth more than a corrected table, because it means any future card quoting a specific grouping shape is quoting an artefact of its fixture's row order. In both shapes the document contained no 186, no partial, no loaded — the defect measured directly rather than inferred.

The two conditions are the design decision, and they are right

Two triggers of deliberately different strength, each matched to wording it can honestly support:

  1. A real total (resolvedTotalMatching exceeds rows in hand) → partial as a fact, stated with both numbers.
  2. No total, but a full window came back → can only ever say "may", because a result set that exactly fills the window trips it too. plugin-list's own footer draws the same inference the same way.

And hasInlineData gates condition 2 out, with the correct reason: inline rows are not a page — nothing was asked for and nothing withheld. A result set that fits leaves the grid silent, "and that silence is what makes the marker mean something when it does appear."

⭐ Reusing resolvedTotalMatching rather than re-spelling the externalManualPagination conditional is the detail I'd flag as load-bearing, and the comment names the precedent: "two copies of it is how one of them got missed in #4464." That is the same two-sites failure #7179 hit, avoided pre-emptively rather than rediscovered.

Tier not engaged — verified, not asserted

GroupRow gains two optional React props; @objectstack/spec is untouched and no schema key is added. So this does not route through CONTRACT_REVIEW_TIER, whose quota is exhausted with two build-ready cards already starved behind it. Getting the disclosure in without spending that slot is what made shipping possible at all.

The defaults are also correctly placed in GRID_DEFAULT_TRANSLATIONS, with the reason stated: a provider-less host must not see a raw key, "and this is a statement about whether the numbers on screen are true."

Ablation: leg B is the one that counts

Predictions written to disk before either leg ran; both matched exactly. Leg A (groupingIsPartial = false) → 8 failed / 5 passed. Leg B (marker made unconditional) → 3 failed / 10 passed, red on the controls only — fits-in-one-page, inline rows, short window.

That second leg is what makes the eight positive pins worth anything: it proves the gate is real rather than that a marker renders. A disclosure that always appears would have passed every positive assertion.

jsdom caveat closed by measurement, not by assumption

My order warned that jsdom applies media queries regardless of innerWidth. Rather than running Chromium reflexively or ignoring the warning, the lane established the warning does not apply — nothing in this disclosure is width-dependent, and grouping never reaches the one width-driven branch (useCardView && data.length > 0 && !isGrouped). Pin 13 asserts that gate in source rather than trusting a jsdom reading. Correct handling: prove the hazard is out of scope, don't just avoid it.

check:doc-snippets (exit 2, its own text: "This is I could not run, NOT I ran and found errors") and check:readme-exports were declared NOT MEASURED, with the diff measured to add zero fenced blocks. Right on both counts.

One finding escalated rather than filed

useServerPagination = !hasInlineData && !isGrouped — I confirmed this independently at ObjectGrid.tsx:3224 on main, and manualPaginationOn excludes grouped too. So a grouped grid that owns its fetch has no server pager at all; its grouped pager pages groups in memory.

⇒ Records past the first $top window are unreachable, not merely undisclosed. No amount of disclosure fixes that, and it makes this PR's notice more necessary rather than less — it now tells a user about data they have no way to reach. Carried to #5560 as a strengthening argument for server-side grouping, which is exactly where it belongs: inside this card's own durable-fix half, not as a new card.

Armed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page - #7226

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure
Sep 1, 2026
Merged

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page#7226
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #7189 — the disclosure half only.

A grouped grid now states, where the group counts are, that it grouped a page.

The card's numbers were stale, so here is today's main

objectui#7179 landed as a6d8b8d44 after the card was written, and it changed
what the card measures: the projection now carries the grouping field, so the
groups resolve and the (empty) collapse is gone. The card's table (four
groups for five units, 33 / 3 / 46 / 18) cannot be reproduced and nothing
here was built against it.

Re-measured on a6d8b8d44 — 186 records over five business units sized
86 / 61 / 31 / 7 / 1, $top: 100, $select confirmed to carry
business_unit:

fixture orderinggroup headers renderedcountsstore holds
contiguous2 of 586, 1486, 61, 31, 7, 1
interleaved5 of 531, 31, 30, 1, 786, 61, 31, 7, 1

In both, the document contained no 186, no "partial", no "loaded". Which
shape you see is a property of row ordering, not of the tree — the defect is
the same one either way, and the disclosure has to be true of both.

The paging footer the card quotes is not the grid's: Showing first N records. More data may be available. lives in
packages/plugin-list/src/ListView.tsx (list.dataLimitReached), a different
component in a different package. A standalone grouped grid renders no footer
at all.

What this adds

useGroupedData's grouping and aggregation algorithm is untouched — it is
correct for what it does, and the counts above are still page slices
afterwards. What was missing is any statement that client-side grouping is
what you are looking at.

  • a short Partial marker beside every group count, at every nesting
    depth
    , carrying the whole sentence as its title and its accessible name;
  • one line directly above the group list, inside the grouped region rather
    than in the paging footer, which is not a statement about what was grouped.

Is a result-set total reachable where the group headers render? Yes

resolvedTotalMatching (ObjectGrid.tsx:3769) is in scope at the grouped render
site (~4688 / ~4755), and it is the same ONE derived value the pager and both
bulk-bar sites already read — deliberately not re-spelled here, per the note
objectui#4464 left on it. It resolves from the grid's own result.total when
the grid owns the fetch, and from a host's rowCount when a host does. Both
paths are pinned.

So the strong wording is supported, and the trigger never outruns what the
component knows:

conditionwording
total known and greater than rows in hand"Grouped over the first 100 of 186 records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here."
no total, window came back full"Grouped over the 100 records loaded. More may match this view…" — may only say may, the same inference plugin-list's own footer draws when no total is known
rows handed in inlinenothing — nothing was asked for and nothing was withheld
result set fits the pagenothing

That last row is the point of the whole thing: a marker that is always on says
nothing.

Deliberately NOT here

Server-side grouping. It is the card's own preferred durable fix, an
API-surface decision open on #5560, and out of scope for this branch: nothing
builds toward it, no flag anticipates it, the fetch is unchanged. That half of
#7189 stays open.

No metadata contract was widened

No schema key was added — the condition is derived from data the grid already
holds. GroupRow (exported from this package) gains two optional props,
partialLabel and partialTitle; @objectstack/spec is untouched.

Tests — 13 new pins, and an ablation in both directions

packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx.
Prediction was written before either leg ran and both matched exactly.

legmutationpredictedobserved
AgroupingIsPartial = false (disclosure removed)8 failed / 5 passed8 failed / 5 passed
BgroupingIsPartial = isGrouped (marker unconditional)3 failed / 10 passed3 failed / 10 passed

Leg B goes red on the controls only — fits-in-one-page, inline rows,
short window — which is what makes the eight positive pins worth anything.

Each leg: mutation proved on disk by injected-marker line count (1) and
removed-expression line count (0) and a blob hash moved off
HEAD:packages/plugin-grid/src/ObjectGrid.tsx
(eaab121121273aea4e6304d96d82f6fa0da6b06d); restore by
git checkout HEAD -- ABSOLUTE_PATH and proved by stategit diff HEAD,
git diff --cached and git status --short all empty and the blob hash back
to HEAD's — with the restore trapped on EXIT INT TERM. No rebuild was needed
or performed: vitest.config.mts:288 aliases @object-ui/plugin-grid to
packages/plugin-grid/src, verified by reading the alias table.

jsdom caveat, closed rather than assumed. jsdom applies CSS media-query
rules irrespective of innerWidth, so a width-dependent reading in it is not a
measurement. This disclosure is not width-dependent: it carries no responsive
visibility utility, and grouping never reaches the width-driven branch —
if (useCardView && data.length &gt; 0 && !isGrouped). The last pin asserts
that gate in source rather than trusting the reading.

Verification, all at 1d8fd9e65 (the final commit)

  • pnpm exec vitest run packages/i18n/ packages/plugin-grid/166 files,
    1912 tests, all passed
    . Includes all-locales-key-parity, which owns the
    ten-pack key sets the three new grid.grouping.* strings had to satisfy.
  • turbo run type-check --filter @object-ui/plugin-grid --filter @object-ui/i18n — 15/15 tasks. tsc -p tsconfig.test.json --listFiles
    confirms the new test file and both edited sources are in that program, so
    "typecheck clean" actually covers them.
  • Consumer sweep, direction dependents of @object-ui/plugin-grid (named
    explicitly, because the ... prefix means opposite things in pnpm and
    turbo): app-shell, console, plugin-designer, plugin-report, plugin-view,
    plugin-list — turbo run type-check, 41/41 tasks.
  • check:i18n-keys, check:i18n-drift (0 en values changed, 3 keys added),
    check:control-bytes, check:doc-types, check:doc-fences,
    check:changeset-presence — all exit 0 at 1d8fd9e65.
  • eslint, plain form, on all 13 changed .ts/.tsx files: 0 errors; the
    new test file is 0 errors / 0 warnings. Type-aware linting is not enabled in
    eslint.config.js (no parserOptions.project), so this diff cannot move a
    verdict in a file it did not touch — the narrowing is a measurement, not a
    skip. The repo-wide scan is CI's.
  • NOT MEASURED locally, declared:check:doc-snippets and
    check:readme-exports. Both exit on an explicit precondition — 21
    packages unbuilt in a fresh worktree — and both judge only fenced code
    blocks. This diff adds zero fenced blocks to any document (measured:
    zero added lines carrying a fence). CI builds and runs them.

Changeset

@object-ui/plugin-grid and @object-ui/i18n, both minor. The docs edits
ride along in the same PR, so no separate empty-frontmatter changeset is owed —
that form is for a docs-only change, where a patch would falsely claim a
released package moved.


Generated by Claude Code

… it grouped a page
`useGroupedData` buckets the rows the browser already holds and computes every
per-group aggregate from that same array, so both the set of groups and every
number in a group header are properties of the fetched page, not of the query.
The algorithm is correct for what it does and is untouched here; what was
missing is any statement that client-side grouping is what you are looking at.
Measured on today's `main` (a6d8b8d) over a 186-record store distributed
86/61/31/7/1 across five business units with a 100-row page: contiguous rows
rendered TWO group headers (86, 14) with three units absent from the screen
entirely; interleaved rows rendered all five with every count a page slice
(31/31/30/1/7). The document contained no "186", no "partial", no "loaded".
The disclosure goes where the authoritative-looking number is: a short
`Partial` marker beside every group count at every depth, carrying the whole
sentence as its title and accessible name, plus one line directly above the
group list inside the grouped region — not in the paging footer, which is not
a statement about what was grouped.
The trigger never outruns what the component knows. A real match total
(`resolvedTotalMatching`, the one derived value the pager and both bulk-bar
sites already read) states the fact with both numbers; a full window with no
total may only say "more may match", the same inference plugin-list's own
footer draws. Inline rows are not a page and are never marked, and a grouped
grid whose result set fits in one page shows nothing at all.
Server-side grouping is deliberately out of scope — an API-surface decision
still open on objectui#5560. Nothing here changes the fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Removes the four `no-explicit-any` warnings the new file carried; the row
shape is a real interface and the find params read through
`Record<string, unknown>`. No assertion changed — still 13 passed (13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation plugin tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-BsrOgisl.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)208.80KB56.56KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.47KB27.66KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM

Verified over git: clean against origin/main5015fcf52 (merge-tree --write-tree exit 0), changeset present, 17 files, all ten locale packs.

My assumption was wrong again, and this one changes the card's framing

The order said the footer already discloses paging and the problem is only where it says so. False:

the footer the card cites is NOT the grid's — Showing first N records. More data may be available. lives in plugin-list/src/ListView.tsx as list.dataLimitReached, a different component in a different package, and a standalone grouped grid renders no footer at all.

So the card's premise — "the footer does disclose it, but the group header is the number people trust" — understates the defect. In the standalone case there was no disclosure anywhere, not a badly-placed one. Fourth PM premise falsified by a lane this shift.

The conclusion survived the falsification, which is the useful part: a total is reachable at the group-header render site via resolvedTotalMatching, so the strong "first 100 of 186" wording is supported rather than assumed.

Re-measuring rather than reproducing the stale table was the right call

The order warned the card's figures predate #7179. What came back is sharper than "the numbers changed":

two group headers (86, 14) with three units absent when rows were contiguous; five headers reading 31/31/30/1/7 when interleaved — which shape appears is a property of row ordering, not of the tree

⇒ Neither the card's 33/3/46/18 nor this lane's numbers are "the" numbers. That is worth more than a corrected table, because it means any future card quoting a specific grouping shape is quoting an artefact of its fixture's row order. In both shapes the document contained no 186, no partial, no loaded — the defect measured directly rather than inferred.

The two conditions are the design decision, and they are right

Two triggers of deliberately different strength, each matched to wording it can honestly support:

  1. A real total (resolvedTotalMatching exceeds rows in hand) → partial as a fact, stated with both numbers.
  2. No total, but a full window came back → can only ever say "may", because a result set that exactly fills the window trips it too. plugin-list's own footer draws the same inference the same way.

And hasInlineData gates condition 2 out, with the correct reason: inline rows are not a page — nothing was asked for and nothing withheld. A result set that fits leaves the grid silent, "and that silence is what makes the marker mean something when it does appear."

⭐ Reusing resolvedTotalMatching rather than re-spelling the externalManualPagination conditional is the detail I'd flag as load-bearing, and the comment names the precedent: "two copies of it is how one of them got missed in #4464." That is the same two-sites failure #7179 hit, avoided pre-emptively rather than rediscovered.

Tier not engaged — verified, not asserted

GroupRow gains two optional React props; @objectstack/spec is untouched and no schema key is added. So this does not route through CONTRACT_REVIEW_TIER, whose quota is exhausted with two build-ready cards already starved behind it. Getting the disclosure in without spending that slot is what made shipping possible at all.

The defaults are also correctly placed in GRID_DEFAULT_TRANSLATIONS, with the reason stated: a provider-less host must not see a raw key, "and this is a statement about whether the numbers on screen are true."

Ablation: leg B is the one that counts

Predictions written to disk before either leg ran; both matched exactly. Leg A (groupingIsPartial = false) → 8 failed / 5 passed. Leg B (marker made unconditional) → 3 failed / 10 passed, red on the controls only — fits-in-one-page, inline rows, short window.

That second leg is what makes the eight positive pins worth anything: it proves the gate is real rather than that a marker renders. A disclosure that always appears would have passed every positive assertion.

jsdom caveat closed by measurement, not by assumption

My order warned that jsdom applies media queries regardless of innerWidth. Rather than running Chromium reflexively or ignoring the warning, the lane established the warning does not apply — nothing in this disclosure is width-dependent, and grouping never reaches the one width-driven branch (useCardView && data.length > 0 && !isGrouped). Pin 13 asserts that gate in source rather than trusting a jsdom reading. Correct handling: prove the hazard is out of scope, don't just avoid it.

check:doc-snippets (exit 2, its own text: "This is I could not run, NOT I ran and found errors") and check:readme-exports were declared NOT MEASURED, with the diff measured to add zero fenced blocks. Right on both counts.

One finding escalated rather than filed

useServerPagination = !hasInlineData && !isGrouped — I confirmed this independently at ObjectGrid.tsx:3224 on main, and manualPaginationOn excludes grouped too. So a grouped grid that owns its fetch has no server pager at all; its grouped pager pages groups in memory.

⇒ Records past the first $top window are unreachable, not merely undisclosed. No amount of disclosure fixes that, and it makes this PR's notice more necessary rather than less — it now tells a user about data they have no way to reach. Carried to #5560 as a strengthening argument for server-side grouping, which is exactly where it belongs: inside this card's own durable-fix half, not as a new card.

Armed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page - #7226

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure
Sep 1, 2026
Merged

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page#7226
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #7189 — the disclosure half only.

A grouped grid now states, where the group counts are, that it grouped a page.

The card's numbers were stale, so here is today's main

objectui#7179 landed as a6d8b8d44 after the card was written, and it changed
what the card measures: the projection now carries the grouping field, so the
groups resolve and the (empty) collapse is gone. The card's table (four
groups for five units, 33 / 3 / 46 / 18) cannot be reproduced and nothing
here was built against it.

Re-measured on a6d8b8d44 — 186 records over five business units sized
86 / 61 / 31 / 7 / 1, $top: 100, $select confirmed to carry
business_unit:

fixture orderinggroup headers renderedcountsstore holds
contiguous2 of 586, 1486, 61, 31, 7, 1
interleaved5 of 531, 31, 30, 1, 786, 61, 31, 7, 1

In both, the document contained no 186, no "partial", no "loaded". Which
shape you see is a property of row ordering, not of the tree — the defect is
the same one either way, and the disclosure has to be true of both.

The paging footer the card quotes is not the grid's: Showing first N records. More data may be available. lives in
packages/plugin-list/src/ListView.tsx (list.dataLimitReached), a different
component in a different package. A standalone grouped grid renders no footer
at all.

What this adds

useGroupedData's grouping and aggregation algorithm is untouched — it is
correct for what it does, and the counts above are still page slices
afterwards. What was missing is any statement that client-side grouping is
what you are looking at.

  • a short Partial marker beside every group count, at every nesting
    depth
    , carrying the whole sentence as its title and its accessible name;
  • one line directly above the group list, inside the grouped region rather
    than in the paging footer, which is not a statement about what was grouped.

Is a result-set total reachable where the group headers render? Yes

resolvedTotalMatching (ObjectGrid.tsx:3769) is in scope at the grouped render
site (~4688 / ~4755), and it is the same ONE derived value the pager and both
bulk-bar sites already read — deliberately not re-spelled here, per the note
objectui#4464 left on it. It resolves from the grid's own result.total when
the grid owns the fetch, and from a host's rowCount when a host does. Both
paths are pinned.

So the strong wording is supported, and the trigger never outruns what the
component knows:

conditionwording
total known and greater than rows in hand"Grouped over the first 100 of 186 records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here."
no total, window came back full"Grouped over the 100 records loaded. More may match this view…" — may only say may, the same inference plugin-list's own footer draws when no total is known
rows handed in inlinenothing — nothing was asked for and nothing was withheld
result set fits the pagenothing

That last row is the point of the whole thing: a marker that is always on says
nothing.

Deliberately NOT here

Server-side grouping. It is the card's own preferred durable fix, an
API-surface decision open on #5560, and out of scope for this branch: nothing
builds toward it, no flag anticipates it, the fetch is unchanged. That half of
#7189 stays open.

No metadata contract was widened

No schema key was added — the condition is derived from data the grid already
holds. GroupRow (exported from this package) gains two optional props,
partialLabel and partialTitle; @objectstack/spec is untouched.

Tests — 13 new pins, and an ablation in both directions

packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx.
Prediction was written before either leg ran and both matched exactly.

legmutationpredictedobserved
AgroupingIsPartial = false (disclosure removed)8 failed / 5 passed8 failed / 5 passed
BgroupingIsPartial = isGrouped (marker unconditional)3 failed / 10 passed3 failed / 10 passed

Leg B goes red on the controls only — fits-in-one-page, inline rows,
short window — which is what makes the eight positive pins worth anything.

Each leg: mutation proved on disk by injected-marker line count (1) and
removed-expression line count (0) and a blob hash moved off
HEAD:packages/plugin-grid/src/ObjectGrid.tsx
(eaab121121273aea4e6304d96d82f6fa0da6b06d); restore by
git checkout HEAD -- ABSOLUTE_PATH and proved by stategit diff HEAD,
git diff --cached and git status --short all empty and the blob hash back
to HEAD's — with the restore trapped on EXIT INT TERM. No rebuild was needed
or performed: vitest.config.mts:288 aliases @object-ui/plugin-grid to
packages/plugin-grid/src, verified by reading the alias table.

jsdom caveat, closed rather than assumed. jsdom applies CSS media-query
rules irrespective of innerWidth, so a width-dependent reading in it is not a
measurement. This disclosure is not width-dependent: it carries no responsive
visibility utility, and grouping never reaches the width-driven branch —
if (useCardView && data.length &gt; 0 && !isGrouped). The last pin asserts
that gate in source rather than trusting the reading.

Verification, all at 1d8fd9e65 (the final commit)

  • pnpm exec vitest run packages/i18n/ packages/plugin-grid/166 files,
    1912 tests, all passed
    . Includes all-locales-key-parity, which owns the
    ten-pack key sets the three new grid.grouping.* strings had to satisfy.
  • turbo run type-check --filter @object-ui/plugin-grid --filter @object-ui/i18n — 15/15 tasks. tsc -p tsconfig.test.json --listFiles
    confirms the new test file and both edited sources are in that program, so
    "typecheck clean" actually covers them.
  • Consumer sweep, direction dependents of @object-ui/plugin-grid (named
    explicitly, because the ... prefix means opposite things in pnpm and
    turbo): app-shell, console, plugin-designer, plugin-report, plugin-view,
    plugin-list — turbo run type-check, 41/41 tasks.
  • check:i18n-keys, check:i18n-drift (0 en values changed, 3 keys added),
    check:control-bytes, check:doc-types, check:doc-fences,
    check:changeset-presence — all exit 0 at 1d8fd9e65.
  • eslint, plain form, on all 13 changed .ts/.tsx files: 0 errors; the
    new test file is 0 errors / 0 warnings. Type-aware linting is not enabled in
    eslint.config.js (no parserOptions.project), so this diff cannot move a
    verdict in a file it did not touch — the narrowing is a measurement, not a
    skip. The repo-wide scan is CI's.
  • NOT MEASURED locally, declared:check:doc-snippets and
    check:readme-exports. Both exit on an explicit precondition — 21
    packages unbuilt in a fresh worktree — and both judge only fenced code
    blocks. This diff adds zero fenced blocks to any document (measured:
    zero added lines carrying a fence). CI builds and runs them.

Changeset

@object-ui/plugin-grid and @object-ui/i18n, both minor. The docs edits
ride along in the same PR, so no separate empty-frontmatter changeset is owed —
that form is for a docs-only change, where a patch would falsely claim a
released package moved.


Generated by Claude Code

… it grouped a page
`useGroupedData` buckets the rows the browser already holds and computes every
per-group aggregate from that same array, so both the set of groups and every
number in a group header are properties of the fetched page, not of the query.
The algorithm is correct for what it does and is untouched here; what was
missing is any statement that client-side grouping is what you are looking at.
Measured on today's `main` (a6d8b8d) over a 186-record store distributed
86/61/31/7/1 across five business units with a 100-row page: contiguous rows
rendered TWO group headers (86, 14) with three units absent from the screen
entirely; interleaved rows rendered all five with every count a page slice
(31/31/30/1/7). The document contained no "186", no "partial", no "loaded".
The disclosure goes where the authoritative-looking number is: a short
`Partial` marker beside every group count at every depth, carrying the whole
sentence as its title and accessible name, plus one line directly above the
group list inside the grouped region — not in the paging footer, which is not
a statement about what was grouped.
The trigger never outruns what the component knows. A real match total
(`resolvedTotalMatching`, the one derived value the pager and both bulk-bar
sites already read) states the fact with both numbers; a full window with no
total may only say "more may match", the same inference plugin-list's own
footer draws. Inline rows are not a page and are never marked, and a grouped
grid whose result set fits in one page shows nothing at all.
Server-side grouping is deliberately out of scope — an API-surface decision
still open on objectui#5560. Nothing here changes the fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Removes the four `no-explicit-any` warnings the new file carried; the row
shape is a real interface and the find params read through
`Record<string, unknown>`. No assertion changed — still 13 passed (13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation plugin tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-BsrOgisl.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)208.80KB56.56KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.47KB27.66KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM

Verified over git: clean against origin/main5015fcf52 (merge-tree --write-tree exit 0), changeset present, 17 files, all ten locale packs.

My assumption was wrong again, and this one changes the card's framing

The order said the footer already discloses paging and the problem is only where it says so. False:

the footer the card cites is NOT the grid's — Showing first N records. More data may be available. lives in plugin-list/src/ListView.tsx as list.dataLimitReached, a different component in a different package, and a standalone grouped grid renders no footer at all.

So the card's premise — "the footer does disclose it, but the group header is the number people trust" — understates the defect. In the standalone case there was no disclosure anywhere, not a badly-placed one. Fourth PM premise falsified by a lane this shift.

The conclusion survived the falsification, which is the useful part: a total is reachable at the group-header render site via resolvedTotalMatching, so the strong "first 100 of 186" wording is supported rather than assumed.

Re-measuring rather than reproducing the stale table was the right call

The order warned the card's figures predate #7179. What came back is sharper than "the numbers changed":

two group headers (86, 14) with three units absent when rows were contiguous; five headers reading 31/31/30/1/7 when interleaved — which shape appears is a property of row ordering, not of the tree

⇒ Neither the card's 33/3/46/18 nor this lane's numbers are "the" numbers. That is worth more than a corrected table, because it means any future card quoting a specific grouping shape is quoting an artefact of its fixture's row order. In both shapes the document contained no 186, no partial, no loaded — the defect measured directly rather than inferred.

The two conditions are the design decision, and they are right

Two triggers of deliberately different strength, each matched to wording it can honestly support:

  1. A real total (resolvedTotalMatching exceeds rows in hand) → partial as a fact, stated with both numbers.
  2. No total, but a full window came back → can only ever say "may", because a result set that exactly fills the window trips it too. plugin-list's own footer draws the same inference the same way.

And hasInlineData gates condition 2 out, with the correct reason: inline rows are not a page — nothing was asked for and nothing withheld. A result set that fits leaves the grid silent, "and that silence is what makes the marker mean something when it does appear."

⭐ Reusing resolvedTotalMatching rather than re-spelling the externalManualPagination conditional is the detail I'd flag as load-bearing, and the comment names the precedent: "two copies of it is how one of them got missed in #4464." That is the same two-sites failure #7179 hit, avoided pre-emptively rather than rediscovered.

Tier not engaged — verified, not asserted

GroupRow gains two optional React props; @objectstack/spec is untouched and no schema key is added. So this does not route through CONTRACT_REVIEW_TIER, whose quota is exhausted with two build-ready cards already starved behind it. Getting the disclosure in without spending that slot is what made shipping possible at all.

The defaults are also correctly placed in GRID_DEFAULT_TRANSLATIONS, with the reason stated: a provider-less host must not see a raw key, "and this is a statement about whether the numbers on screen are true."

Ablation: leg B is the one that counts

Predictions written to disk before either leg ran; both matched exactly. Leg A (groupingIsPartial = false) → 8 failed / 5 passed. Leg B (marker made unconditional) → 3 failed / 10 passed, red on the controls only — fits-in-one-page, inline rows, short window.

That second leg is what makes the eight positive pins worth anything: it proves the gate is real rather than that a marker renders. A disclosure that always appears would have passed every positive assertion.

jsdom caveat closed by measurement, not by assumption

My order warned that jsdom applies media queries regardless of innerWidth. Rather than running Chromium reflexively or ignoring the warning, the lane established the warning does not apply — nothing in this disclosure is width-dependent, and grouping never reaches the one width-driven branch (useCardView && data.length > 0 && !isGrouped). Pin 13 asserts that gate in source rather than trusting a jsdom reading. Correct handling: prove the hazard is out of scope, don't just avoid it.

check:doc-snippets (exit 2, its own text: "This is I could not run, NOT I ran and found errors") and check:readme-exports were declared NOT MEASURED, with the diff measured to add zero fenced blocks. Right on both counts.

One finding escalated rather than filed

useServerPagination = !hasInlineData && !isGrouped — I confirmed this independently at ObjectGrid.tsx:3224 on main, and manualPaginationOn excludes grouped too. So a grouped grid that owns its fetch has no server pager at all; its grouped pager pages groups in memory.

⇒ Records past the first $top window are unreachable, not merely undisclosed. No amount of disclosure fixes that, and it makes this PR's notice more necessary rather than less — it now tells a user about data they have no way to reach. Carried to #5560 as a strengthening argument for server-side grouping, which is exactly where it belongs: inside this card's own durable-fix half, not as a new card.

Armed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page - #7226

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure
Sep 1, 2026
Merged

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page#7226
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #7189 — the disclosure half only.

A grouped grid now states, where the group counts are, that it grouped a page.

The card's numbers were stale, so here is today's main

objectui#7179 landed as a6d8b8d44 after the card was written, and it changed
what the card measures: the projection now carries the grouping field, so the
groups resolve and the (empty) collapse is gone. The card's table (four
groups for five units, 33 / 3 / 46 / 18) cannot be reproduced and nothing
here was built against it.

Re-measured on a6d8b8d44 — 186 records over five business units sized
86 / 61 / 31 / 7 / 1, $top: 100, $select confirmed to carry
business_unit:

fixture orderinggroup headers renderedcountsstore holds
contiguous2 of 586, 1486, 61, 31, 7, 1
interleaved5 of 531, 31, 30, 1, 786, 61, 31, 7, 1

In both, the document contained no 186, no "partial", no "loaded". Which
shape you see is a property of row ordering, not of the tree — the defect is
the same one either way, and the disclosure has to be true of both.

The paging footer the card quotes is not the grid's: Showing first N records. More data may be available. lives in
packages/plugin-list/src/ListView.tsx (list.dataLimitReached), a different
component in a different package. A standalone grouped grid renders no footer
at all.

What this adds

useGroupedData's grouping and aggregation algorithm is untouched — it is
correct for what it does, and the counts above are still page slices
afterwards. What was missing is any statement that client-side grouping is
what you are looking at.

  • a short Partial marker beside every group count, at every nesting
    depth
    , carrying the whole sentence as its title and its accessible name;
  • one line directly above the group list, inside the grouped region rather
    than in the paging footer, which is not a statement about what was grouped.

Is a result-set total reachable where the group headers render? Yes

resolvedTotalMatching (ObjectGrid.tsx:3769) is in scope at the grouped render
site (~4688 / ~4755), and it is the same ONE derived value the pager and both
bulk-bar sites already read — deliberately not re-spelled here, per the note
objectui#4464 left on it. It resolves from the grid's own result.total when
the grid owns the fetch, and from a host's rowCount when a host does. Both
paths are pinned.

So the strong wording is supported, and the trigger never outruns what the
component knows:

conditionwording
total known and greater than rows in hand"Grouped over the first 100 of 186 records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here."
no total, window came back full"Grouped over the 100 records loaded. More may match this view…" — may only say may, the same inference plugin-list's own footer draws when no total is known
rows handed in inlinenothing — nothing was asked for and nothing was withheld
result set fits the pagenothing

That last row is the point of the whole thing: a marker that is always on says
nothing.

Deliberately NOT here

Server-side grouping. It is the card's own preferred durable fix, an
API-surface decision open on #5560, and out of scope for this branch: nothing
builds toward it, no flag anticipates it, the fetch is unchanged. That half of
#7189 stays open.

No metadata contract was widened

No schema key was added — the condition is derived from data the grid already
holds. GroupRow (exported from this package) gains two optional props,
partialLabel and partialTitle; @objectstack/spec is untouched.

Tests — 13 new pins, and an ablation in both directions

packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx.
Prediction was written before either leg ran and both matched exactly.

legmutationpredictedobserved
AgroupingIsPartial = false (disclosure removed)8 failed / 5 passed8 failed / 5 passed
BgroupingIsPartial = isGrouped (marker unconditional)3 failed / 10 passed3 failed / 10 passed

Leg B goes red on the controls only — fits-in-one-page, inline rows,
short window — which is what makes the eight positive pins worth anything.

Each leg: mutation proved on disk by injected-marker line count (1) and
removed-expression line count (0) and a blob hash moved off
HEAD:packages/plugin-grid/src/ObjectGrid.tsx
(eaab121121273aea4e6304d96d82f6fa0da6b06d); restore by
git checkout HEAD -- ABSOLUTE_PATH and proved by stategit diff HEAD,
git diff --cached and git status --short all empty and the blob hash back
to HEAD's — with the restore trapped on EXIT INT TERM. No rebuild was needed
or performed: vitest.config.mts:288 aliases @object-ui/plugin-grid to
packages/plugin-grid/src, verified by reading the alias table.

jsdom caveat, closed rather than assumed. jsdom applies CSS media-query
rules irrespective of innerWidth, so a width-dependent reading in it is not a
measurement. This disclosure is not width-dependent: it carries no responsive
visibility utility, and grouping never reaches the width-driven branch —
if (useCardView && data.length &gt; 0 && !isGrouped). The last pin asserts
that gate in source rather than trusting the reading.

Verification, all at 1d8fd9e65 (the final commit)

  • pnpm exec vitest run packages/i18n/ packages/plugin-grid/166 files,
    1912 tests, all passed
    . Includes all-locales-key-parity, which owns the
    ten-pack key sets the three new grid.grouping.* strings had to satisfy.
  • turbo run type-check --filter @object-ui/plugin-grid --filter @object-ui/i18n — 15/15 tasks. tsc -p tsconfig.test.json --listFiles
    confirms the new test file and both edited sources are in that program, so
    "typecheck clean" actually covers them.
  • Consumer sweep, direction dependents of @object-ui/plugin-grid (named
    explicitly, because the ... prefix means opposite things in pnpm and
    turbo): app-shell, console, plugin-designer, plugin-report, plugin-view,
    plugin-list — turbo run type-check, 41/41 tasks.
  • check:i18n-keys, check:i18n-drift (0 en values changed, 3 keys added),
    check:control-bytes, check:doc-types, check:doc-fences,
    check:changeset-presence — all exit 0 at 1d8fd9e65.
  • eslint, plain form, on all 13 changed .ts/.tsx files: 0 errors; the
    new test file is 0 errors / 0 warnings. Type-aware linting is not enabled in
    eslint.config.js (no parserOptions.project), so this diff cannot move a
    verdict in a file it did not touch — the narrowing is a measurement, not a
    skip. The repo-wide scan is CI's.
  • NOT MEASURED locally, declared:check:doc-snippets and
    check:readme-exports. Both exit on an explicit precondition — 21
    packages unbuilt in a fresh worktree — and both judge only fenced code
    blocks. This diff adds zero fenced blocks to any document (measured:
    zero added lines carrying a fence). CI builds and runs them.

Changeset

@object-ui/plugin-grid and @object-ui/i18n, both minor. The docs edits
ride along in the same PR, so no separate empty-frontmatter changeset is owed —
that form is for a docs-only change, where a patch would falsely claim a
released package moved.


Generated by Claude Code

… it grouped a page
`useGroupedData` buckets the rows the browser already holds and computes every
per-group aggregate from that same array, so both the set of groups and every
number in a group header are properties of the fetched page, not of the query.
The algorithm is correct for what it does and is untouched here; what was
missing is any statement that client-side grouping is what you are looking at.
Measured on today's `main` (a6d8b8d) over a 186-record store distributed
86/61/31/7/1 across five business units with a 100-row page: contiguous rows
rendered TWO group headers (86, 14) with three units absent from the screen
entirely; interleaved rows rendered all five with every count a page slice
(31/31/30/1/7). The document contained no "186", no "partial", no "loaded".
The disclosure goes where the authoritative-looking number is: a short
`Partial` marker beside every group count at every depth, carrying the whole
sentence as its title and accessible name, plus one line directly above the
group list inside the grouped region — not in the paging footer, which is not
a statement about what was grouped.
The trigger never outruns what the component knows. A real match total
(`resolvedTotalMatching`, the one derived value the pager and both bulk-bar
sites already read) states the fact with both numbers; a full window with no
total may only say "more may match", the same inference plugin-list's own
footer draws. Inline rows are not a page and are never marked, and a grouped
grid whose result set fits in one page shows nothing at all.
Server-side grouping is deliberately out of scope — an API-surface decision
still open on objectui#5560. Nothing here changes the fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Removes the four `no-explicit-any` warnings the new file carried; the row
shape is a real interface and the find params read through
`Record<string, unknown>`. No assertion changed — still 13 passed (13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation plugin tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-BsrOgisl.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)208.80KB56.56KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.47KB27.66KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM

Verified over git: clean against origin/main5015fcf52 (merge-tree --write-tree exit 0), changeset present, 17 files, all ten locale packs.

My assumption was wrong again, and this one changes the card's framing

The order said the footer already discloses paging and the problem is only where it says so. False:

the footer the card cites is NOT the grid's — Showing first N records. More data may be available. lives in plugin-list/src/ListView.tsx as list.dataLimitReached, a different component in a different package, and a standalone grouped grid renders no footer at all.

So the card's premise — "the footer does disclose it, but the group header is the number people trust" — understates the defect. In the standalone case there was no disclosure anywhere, not a badly-placed one. Fourth PM premise falsified by a lane this shift.

The conclusion survived the falsification, which is the useful part: a total is reachable at the group-header render site via resolvedTotalMatching, so the strong "first 100 of 186" wording is supported rather than assumed.

Re-measuring rather than reproducing the stale table was the right call

The order warned the card's figures predate #7179. What came back is sharper than "the numbers changed":

two group headers (86, 14) with three units absent when rows were contiguous; five headers reading 31/31/30/1/7 when interleaved — which shape appears is a property of row ordering, not of the tree

⇒ Neither the card's 33/3/46/18 nor this lane's numbers are "the" numbers. That is worth more than a corrected table, because it means any future card quoting a specific grouping shape is quoting an artefact of its fixture's row order. In both shapes the document contained no 186, no partial, no loaded — the defect measured directly rather than inferred.

The two conditions are the design decision, and they are right

Two triggers of deliberately different strength, each matched to wording it can honestly support:

  1. A real total (resolvedTotalMatching exceeds rows in hand) → partial as a fact, stated with both numbers.
  2. No total, but a full window came back → can only ever say "may", because a result set that exactly fills the window trips it too. plugin-list's own footer draws the same inference the same way.

And hasInlineData gates condition 2 out, with the correct reason: inline rows are not a page — nothing was asked for and nothing withheld. A result set that fits leaves the grid silent, "and that silence is what makes the marker mean something when it does appear."

⭐ Reusing resolvedTotalMatching rather than re-spelling the externalManualPagination conditional is the detail I'd flag as load-bearing, and the comment names the precedent: "two copies of it is how one of them got missed in #4464." That is the same two-sites failure #7179 hit, avoided pre-emptively rather than rediscovered.

Tier not engaged — verified, not asserted

GroupRow gains two optional React props; @objectstack/spec is untouched and no schema key is added. So this does not route through CONTRACT_REVIEW_TIER, whose quota is exhausted with two build-ready cards already starved behind it. Getting the disclosure in without spending that slot is what made shipping possible at all.

The defaults are also correctly placed in GRID_DEFAULT_TRANSLATIONS, with the reason stated: a provider-less host must not see a raw key, "and this is a statement about whether the numbers on screen are true."

Ablation: leg B is the one that counts

Predictions written to disk before either leg ran; both matched exactly. Leg A (groupingIsPartial = false) → 8 failed / 5 passed. Leg B (marker made unconditional) → 3 failed / 10 passed, red on the controls only — fits-in-one-page, inline rows, short window.

That second leg is what makes the eight positive pins worth anything: it proves the gate is real rather than that a marker renders. A disclosure that always appears would have passed every positive assertion.

jsdom caveat closed by measurement, not by assumption

My order warned that jsdom applies media queries regardless of innerWidth. Rather than running Chromium reflexively or ignoring the warning, the lane established the warning does not apply — nothing in this disclosure is width-dependent, and grouping never reaches the one width-driven branch (useCardView && data.length > 0 && !isGrouped). Pin 13 asserts that gate in source rather than trusting a jsdom reading. Correct handling: prove the hazard is out of scope, don't just avoid it.

check:doc-snippets (exit 2, its own text: "This is I could not run, NOT I ran and found errors") and check:readme-exports were declared NOT MEASURED, with the diff measured to add zero fenced blocks. Right on both counts.

One finding escalated rather than filed

useServerPagination = !hasInlineData && !isGrouped — I confirmed this independently at ObjectGrid.tsx:3224 on main, and manualPaginationOn excludes grouped too. So a grouped grid that owns its fetch has no server pager at all; its grouped pager pages groups in memory.

⇒ Records past the first $top window are unreachable, not merely undisclosed. No amount of disclosure fixes that, and it makes this PR's notice more necessary rather than less — it now tells a user about data they have no way to reach. Carried to #5560 as a strengthening argument for server-side grouping, which is exactly where it belongs: inside this card's own durable-fix half, not as a new card.

Armed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page - #7226

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure
Sep 1, 2026
Merged

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page#7226
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #7189 — the disclosure half only.

A grouped grid now states, where the group counts are, that it grouped a page.

The card's numbers were stale, so here is today's main

objectui#7179 landed as a6d8b8d44 after the card was written, and it changed
what the card measures: the projection now carries the grouping field, so the
groups resolve and the (empty) collapse is gone. The card's table (four
groups for five units, 33 / 3 / 46 / 18) cannot be reproduced and nothing
here was built against it.

Re-measured on a6d8b8d44 — 186 records over five business units sized
86 / 61 / 31 / 7 / 1, $top: 100, $select confirmed to carry
business_unit:

fixture orderinggroup headers renderedcountsstore holds
contiguous2 of 586, 1486, 61, 31, 7, 1
interleaved5 of 531, 31, 30, 1, 786, 61, 31, 7, 1

In both, the document contained no 186, no "partial", no "loaded". Which
shape you see is a property of row ordering, not of the tree — the defect is
the same one either way, and the disclosure has to be true of both.

The paging footer the card quotes is not the grid's: Showing first N records. More data may be available. lives in
packages/plugin-list/src/ListView.tsx (list.dataLimitReached), a different
component in a different package. A standalone grouped grid renders no footer
at all.

What this adds

useGroupedData's grouping and aggregation algorithm is untouched — it is
correct for what it does, and the counts above are still page slices
afterwards. What was missing is any statement that client-side grouping is
what you are looking at.

  • a short Partial marker beside every group count, at every nesting
    depth
    , carrying the whole sentence as its title and its accessible name;
  • one line directly above the group list, inside the grouped region rather
    than in the paging footer, which is not a statement about what was grouped.

Is a result-set total reachable where the group headers render? Yes

resolvedTotalMatching (ObjectGrid.tsx:3769) is in scope at the grouped render
site (~4688 / ~4755), and it is the same ONE derived value the pager and both
bulk-bar sites already read — deliberately not re-spelled here, per the note
objectui#4464 left on it. It resolves from the grid's own result.total when
the grid owns the fetch, and from a host's rowCount when a host does. Both
paths are pinned.

So the strong wording is supported, and the trigger never outruns what the
component knows:

conditionwording
total known and greater than rows in hand"Grouped over the first 100 of 186 records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here."
no total, window came back full"Grouped over the 100 records loaded. More may match this view…" — may only say may, the same inference plugin-list's own footer draws when no total is known
rows handed in inlinenothing — nothing was asked for and nothing was withheld
result set fits the pagenothing

That last row is the point of the whole thing: a marker that is always on says
nothing.

Deliberately NOT here

Server-side grouping. It is the card's own preferred durable fix, an
API-surface decision open on #5560, and out of scope for this branch: nothing
builds toward it, no flag anticipates it, the fetch is unchanged. That half of
#7189 stays open.

No metadata contract was widened

No schema key was added — the condition is derived from data the grid already
holds. GroupRow (exported from this package) gains two optional props,
partialLabel and partialTitle; @objectstack/spec is untouched.

Tests — 13 new pins, and an ablation in both directions

packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx.
Prediction was written before either leg ran and both matched exactly.

legmutationpredictedobserved
AgroupingIsPartial = false (disclosure removed)8 failed / 5 passed8 failed / 5 passed
BgroupingIsPartial = isGrouped (marker unconditional)3 failed / 10 passed3 failed / 10 passed

Leg B goes red on the controls only — fits-in-one-page, inline rows,
short window — which is what makes the eight positive pins worth anything.

Each leg: mutation proved on disk by injected-marker line count (1) and
removed-expression line count (0) and a blob hash moved off
HEAD:packages/plugin-grid/src/ObjectGrid.tsx
(eaab121121273aea4e6304d96d82f6fa0da6b06d); restore by
git checkout HEAD -- ABSOLUTE_PATH and proved by stategit diff HEAD,
git diff --cached and git status --short all empty and the blob hash back
to HEAD's — with the restore trapped on EXIT INT TERM. No rebuild was needed
or performed: vitest.config.mts:288 aliases @object-ui/plugin-grid to
packages/plugin-grid/src, verified by reading the alias table.

jsdom caveat, closed rather than assumed. jsdom applies CSS media-query
rules irrespective of innerWidth, so a width-dependent reading in it is not a
measurement. This disclosure is not width-dependent: it carries no responsive
visibility utility, and grouping never reaches the width-driven branch —
if (useCardView && data.length &gt; 0 && !isGrouped). The last pin asserts
that gate in source rather than trusting the reading.

Verification, all at 1d8fd9e65 (the final commit)

  • pnpm exec vitest run packages/i18n/ packages/plugin-grid/166 files,
    1912 tests, all passed
    . Includes all-locales-key-parity, which owns the
    ten-pack key sets the three new grid.grouping.* strings had to satisfy.
  • turbo run type-check --filter @object-ui/plugin-grid --filter @object-ui/i18n — 15/15 tasks. tsc -p tsconfig.test.json --listFiles
    confirms the new test file and both edited sources are in that program, so
    "typecheck clean" actually covers them.
  • Consumer sweep, direction dependents of @object-ui/plugin-grid (named
    explicitly, because the ... prefix means opposite things in pnpm and
    turbo): app-shell, console, plugin-designer, plugin-report, plugin-view,
    plugin-list — turbo run type-check, 41/41 tasks.
  • check:i18n-keys, check:i18n-drift (0 en values changed, 3 keys added),
    check:control-bytes, check:doc-types, check:doc-fences,
    check:changeset-presence — all exit 0 at 1d8fd9e65.
  • eslint, plain form, on all 13 changed .ts/.tsx files: 0 errors; the
    new test file is 0 errors / 0 warnings. Type-aware linting is not enabled in
    eslint.config.js (no parserOptions.project), so this diff cannot move a
    verdict in a file it did not touch — the narrowing is a measurement, not a
    skip. The repo-wide scan is CI's.
  • NOT MEASURED locally, declared:check:doc-snippets and
    check:readme-exports. Both exit on an explicit precondition — 21
    packages unbuilt in a fresh worktree — and both judge only fenced code
    blocks. This diff adds zero fenced blocks to any document (measured:
    zero added lines carrying a fence). CI builds and runs them.

Changeset

@object-ui/plugin-grid and @object-ui/i18n, both minor. The docs edits
ride along in the same PR, so no separate empty-frontmatter changeset is owed —
that form is for a docs-only change, where a patch would falsely claim a
released package moved.


Generated by Claude Code

… it grouped a page
`useGroupedData` buckets the rows the browser already holds and computes every
per-group aggregate from that same array, so both the set of groups and every
number in a group header are properties of the fetched page, not of the query.
The algorithm is correct for what it does and is untouched here; what was
missing is any statement that client-side grouping is what you are looking at.
Measured on today's `main` (a6d8b8d) over a 186-record store distributed
86/61/31/7/1 across five business units with a 100-row page: contiguous rows
rendered TWO group headers (86, 14) with three units absent from the screen
entirely; interleaved rows rendered all five with every count a page slice
(31/31/30/1/7). The document contained no "186", no "partial", no "loaded".
The disclosure goes where the authoritative-looking number is: a short
`Partial` marker beside every group count at every depth, carrying the whole
sentence as its title and accessible name, plus one line directly above the
group list inside the grouped region — not in the paging footer, which is not
a statement about what was grouped.
The trigger never outruns what the component knows. A real match total
(`resolvedTotalMatching`, the one derived value the pager and both bulk-bar
sites already read) states the fact with both numbers; a full window with no
total may only say "more may match", the same inference plugin-list's own
footer draws. Inline rows are not a page and are never marked, and a grouped
grid whose result set fits in one page shows nothing at all.
Server-side grouping is deliberately out of scope — an API-surface decision
still open on objectui#5560. Nothing here changes the fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Removes the four `no-explicit-any` warnings the new file carried; the row
shape is a real interface and the find params read through
`Record<string, unknown>`. No assertion changed — still 13 passed (13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation plugin tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-BsrOgisl.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)208.80KB56.56KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.47KB27.66KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM

Verified over git: clean against origin/main5015fcf52 (merge-tree --write-tree exit 0), changeset present, 17 files, all ten locale packs.

My assumption was wrong again, and this one changes the card's framing

The order said the footer already discloses paging and the problem is only where it says so. False:

the footer the card cites is NOT the grid's — Showing first N records. More data may be available. lives in plugin-list/src/ListView.tsx as list.dataLimitReached, a different component in a different package, and a standalone grouped grid renders no footer at all.

So the card's premise — "the footer does disclose it, but the group header is the number people trust" — understates the defect. In the standalone case there was no disclosure anywhere, not a badly-placed one. Fourth PM premise falsified by a lane this shift.

The conclusion survived the falsification, which is the useful part: a total is reachable at the group-header render site via resolvedTotalMatching, so the strong "first 100 of 186" wording is supported rather than assumed.

Re-measuring rather than reproducing the stale table was the right call

The order warned the card's figures predate #7179. What came back is sharper than "the numbers changed":

two group headers (86, 14) with three units absent when rows were contiguous; five headers reading 31/31/30/1/7 when interleaved — which shape appears is a property of row ordering, not of the tree

⇒ Neither the card's 33/3/46/18 nor this lane's numbers are "the" numbers. That is worth more than a corrected table, because it means any future card quoting a specific grouping shape is quoting an artefact of its fixture's row order. In both shapes the document contained no 186, no partial, no loaded — the defect measured directly rather than inferred.

The two conditions are the design decision, and they are right

Two triggers of deliberately different strength, each matched to wording it can honestly support:

  1. A real total (resolvedTotalMatching exceeds rows in hand) → partial as a fact, stated with both numbers.
  2. No total, but a full window came back → can only ever say "may", because a result set that exactly fills the window trips it too. plugin-list's own footer draws the same inference the same way.

And hasInlineData gates condition 2 out, with the correct reason: inline rows are not a page — nothing was asked for and nothing withheld. A result set that fits leaves the grid silent, "and that silence is what makes the marker mean something when it does appear."

⭐ Reusing resolvedTotalMatching rather than re-spelling the externalManualPagination conditional is the detail I'd flag as load-bearing, and the comment names the precedent: "two copies of it is how one of them got missed in #4464." That is the same two-sites failure #7179 hit, avoided pre-emptively rather than rediscovered.

Tier not engaged — verified, not asserted

GroupRow gains two optional React props; @objectstack/spec is untouched and no schema key is added. So this does not route through CONTRACT_REVIEW_TIER, whose quota is exhausted with two build-ready cards already starved behind it. Getting the disclosure in without spending that slot is what made shipping possible at all.

The defaults are also correctly placed in GRID_DEFAULT_TRANSLATIONS, with the reason stated: a provider-less host must not see a raw key, "and this is a statement about whether the numbers on screen are true."

Ablation: leg B is the one that counts

Predictions written to disk before either leg ran; both matched exactly. Leg A (groupingIsPartial = false) → 8 failed / 5 passed. Leg B (marker made unconditional) → 3 failed / 10 passed, red on the controls only — fits-in-one-page, inline rows, short window.

That second leg is what makes the eight positive pins worth anything: it proves the gate is real rather than that a marker renders. A disclosure that always appears would have passed every positive assertion.

jsdom caveat closed by measurement, not by assumption

My order warned that jsdom applies media queries regardless of innerWidth. Rather than running Chromium reflexively or ignoring the warning, the lane established the warning does not apply — nothing in this disclosure is width-dependent, and grouping never reaches the one width-driven branch (useCardView && data.length > 0 && !isGrouped). Pin 13 asserts that gate in source rather than trusting a jsdom reading. Correct handling: prove the hazard is out of scope, don't just avoid it.

check:doc-snippets (exit 2, its own text: "This is I could not run, NOT I ran and found errors") and check:readme-exports were declared NOT MEASURED, with the diff measured to add zero fenced blocks. Right on both counts.

One finding escalated rather than filed

useServerPagination = !hasInlineData && !isGrouped — I confirmed this independently at ObjectGrid.tsx:3224 on main, and manualPaginationOn excludes grouped too. So a grouped grid that owns its fetch has no server pager at all; its grouped pager pages groups in memory.

⇒ Records past the first $top window are unreachable, not merely undisclosed. No amount of disclosure fixes that, and it makes this PR's notice more necessary rather than less — it now tells a user about data they have no way to reach. Carried to #5560 as a strengthening argument for server-side grouping, which is exactly where it belongs: inside this card's own durable-fix half, not as a new card.

Armed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page - #7226

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure
Sep 1, 2026
Merged

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page#7226
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #7189 — the disclosure half only.

A grouped grid now states, where the group counts are, that it grouped a page.

The card's numbers were stale, so here is today's main

objectui#7179 landed as a6d8b8d44 after the card was written, and it changed
what the card measures: the projection now carries the grouping field, so the
groups resolve and the (empty) collapse is gone. The card's table (four
groups for five units, 33 / 3 / 46 / 18) cannot be reproduced and nothing
here was built against it.

Re-measured on a6d8b8d44 — 186 records over five business units sized
86 / 61 / 31 / 7 / 1, $top: 100, $select confirmed to carry
business_unit:

fixture orderinggroup headers renderedcountsstore holds
contiguous2 of 586, 1486, 61, 31, 7, 1
interleaved5 of 531, 31, 30, 1, 786, 61, 31, 7, 1

In both, the document contained no 186, no "partial", no "loaded". Which
shape you see is a property of row ordering, not of the tree — the defect is
the same one either way, and the disclosure has to be true of both.

The paging footer the card quotes is not the grid's: Showing first N records. More data may be available. lives in
packages/plugin-list/src/ListView.tsx (list.dataLimitReached), a different
component in a different package. A standalone grouped grid renders no footer
at all.

What this adds

useGroupedData's grouping and aggregation algorithm is untouched — it is
correct for what it does, and the counts above are still page slices
afterwards. What was missing is any statement that client-side grouping is
what you are looking at.

  • a short Partial marker beside every group count, at every nesting
    depth
    , carrying the whole sentence as its title and its accessible name;
  • one line directly above the group list, inside the grouped region rather
    than in the paging footer, which is not a statement about what was grouped.

Is a result-set total reachable where the group headers render? Yes

resolvedTotalMatching (ObjectGrid.tsx:3769) is in scope at the grouped render
site (~4688 / ~4755), and it is the same ONE derived value the pager and both
bulk-bar sites already read — deliberately not re-spelled here, per the note
objectui#4464 left on it. It resolves from the grid's own result.total when
the grid owns the fetch, and from a host's rowCount when a host does. Both
paths are pinned.

So the strong wording is supported, and the trigger never outruns what the
component knows:

conditionwording
total known and greater than rows in hand"Grouped over the first 100 of 186 records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here."
no total, window came back full"Grouped over the 100 records loaded. More may match this view…" — may only say may, the same inference plugin-list's own footer draws when no total is known
rows handed in inlinenothing — nothing was asked for and nothing was withheld
result set fits the pagenothing

That last row is the point of the whole thing: a marker that is always on says
nothing.

Deliberately NOT here

Server-side grouping. It is the card's own preferred durable fix, an
API-surface decision open on #5560, and out of scope for this branch: nothing
builds toward it, no flag anticipates it, the fetch is unchanged. That half of
#7189 stays open.

No metadata contract was widened

No schema key was added — the condition is derived from data the grid already
holds. GroupRow (exported from this package) gains two optional props,
partialLabel and partialTitle; @objectstack/spec is untouched.

Tests — 13 new pins, and an ablation in both directions

packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx.
Prediction was written before either leg ran and both matched exactly.

legmutationpredictedobserved
AgroupingIsPartial = false (disclosure removed)8 failed / 5 passed8 failed / 5 passed
BgroupingIsPartial = isGrouped (marker unconditional)3 failed / 10 passed3 failed / 10 passed

Leg B goes red on the controls only — fits-in-one-page, inline rows,
short window — which is what makes the eight positive pins worth anything.

Each leg: mutation proved on disk by injected-marker line count (1) and
removed-expression line count (0) and a blob hash moved off
HEAD:packages/plugin-grid/src/ObjectGrid.tsx
(eaab121121273aea4e6304d96d82f6fa0da6b06d); restore by
git checkout HEAD -- ABSOLUTE_PATH and proved by stategit diff HEAD,
git diff --cached and git status --short all empty and the blob hash back
to HEAD's — with the restore trapped on EXIT INT TERM. No rebuild was needed
or performed: vitest.config.mts:288 aliases @object-ui/plugin-grid to
packages/plugin-grid/src, verified by reading the alias table.

jsdom caveat, closed rather than assumed. jsdom applies CSS media-query
rules irrespective of innerWidth, so a width-dependent reading in it is not a
measurement. This disclosure is not width-dependent: it carries no responsive
visibility utility, and grouping never reaches the width-driven branch —
if (useCardView && data.length &gt; 0 && !isGrouped). The last pin asserts
that gate in source rather than trusting the reading.

Verification, all at 1d8fd9e65 (the final commit)

  • pnpm exec vitest run packages/i18n/ packages/plugin-grid/166 files,
    1912 tests, all passed
    . Includes all-locales-key-parity, which owns the
    ten-pack key sets the three new grid.grouping.* strings had to satisfy.
  • turbo run type-check --filter @object-ui/plugin-grid --filter @object-ui/i18n — 15/15 tasks. tsc -p tsconfig.test.json --listFiles
    confirms the new test file and both edited sources are in that program, so
    "typecheck clean" actually covers them.
  • Consumer sweep, direction dependents of @object-ui/plugin-grid (named
    explicitly, because the ... prefix means opposite things in pnpm and
    turbo): app-shell, console, plugin-designer, plugin-report, plugin-view,
    plugin-list — turbo run type-check, 41/41 tasks.
  • check:i18n-keys, check:i18n-drift (0 en values changed, 3 keys added),
    check:control-bytes, check:doc-types, check:doc-fences,
    check:changeset-presence — all exit 0 at 1d8fd9e65.
  • eslint, plain form, on all 13 changed .ts/.tsx files: 0 errors; the
    new test file is 0 errors / 0 warnings. Type-aware linting is not enabled in
    eslint.config.js (no parserOptions.project), so this diff cannot move a
    verdict in a file it did not touch — the narrowing is a measurement, not a
    skip. The repo-wide scan is CI's.
  • NOT MEASURED locally, declared:check:doc-snippets and
    check:readme-exports. Both exit on an explicit precondition — 21
    packages unbuilt in a fresh worktree — and both judge only fenced code
    blocks. This diff adds zero fenced blocks to any document (measured:
    zero added lines carrying a fence). CI builds and runs them.

Changeset

@object-ui/plugin-grid and @object-ui/i18n, both minor. The docs edits
ride along in the same PR, so no separate empty-frontmatter changeset is owed —
that form is for a docs-only change, where a patch would falsely claim a
released package moved.


Generated by Claude Code

… it grouped a page
`useGroupedData` buckets the rows the browser already holds and computes every
per-group aggregate from that same array, so both the set of groups and every
number in a group header are properties of the fetched page, not of the query.
The algorithm is correct for what it does and is untouched here; what was
missing is any statement that client-side grouping is what you are looking at.
Measured on today's `main` (a6d8b8d) over a 186-record store distributed
86/61/31/7/1 across five business units with a 100-row page: contiguous rows
rendered TWO group headers (86, 14) with three units absent from the screen
entirely; interleaved rows rendered all five with every count a page slice
(31/31/30/1/7). The document contained no "186", no "partial", no "loaded".
The disclosure goes where the authoritative-looking number is: a short
`Partial` marker beside every group count at every depth, carrying the whole
sentence as its title and accessible name, plus one line directly above the
group list inside the grouped region — not in the paging footer, which is not
a statement about what was grouped.
The trigger never outruns what the component knows. A real match total
(`resolvedTotalMatching`, the one derived value the pager and both bulk-bar
sites already read) states the fact with both numbers; a full window with no
total may only say "more may match", the same inference plugin-list's own
footer draws. Inline rows are not a page and are never marked, and a grouped
grid whose result set fits in one page shows nothing at all.
Server-side grouping is deliberately out of scope — an API-surface decision
still open on objectui#5560. Nothing here changes the fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Removes the four `no-explicit-any` warnings the new file carried; the row
shape is a real interface and the find params read through
`Record<string, unknown>`. No assertion changed — still 13 passed (13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation plugin tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-BsrOgisl.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)208.80KB56.56KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.47KB27.66KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM

Verified over git: clean against origin/main5015fcf52 (merge-tree --write-tree exit 0), changeset present, 17 files, all ten locale packs.

My assumption was wrong again, and this one changes the card's framing

The order said the footer already discloses paging and the problem is only where it says so. False:

the footer the card cites is NOT the grid's — Showing first N records. More data may be available. lives in plugin-list/src/ListView.tsx as list.dataLimitReached, a different component in a different package, and a standalone grouped grid renders no footer at all.

So the card's premise — "the footer does disclose it, but the group header is the number people trust" — understates the defect. In the standalone case there was no disclosure anywhere, not a badly-placed one. Fourth PM premise falsified by a lane this shift.

The conclusion survived the falsification, which is the useful part: a total is reachable at the group-header render site via resolvedTotalMatching, so the strong "first 100 of 186" wording is supported rather than assumed.

Re-measuring rather than reproducing the stale table was the right call

The order warned the card's figures predate #7179. What came back is sharper than "the numbers changed":

two group headers (86, 14) with three units absent when rows were contiguous; five headers reading 31/31/30/1/7 when interleaved — which shape appears is a property of row ordering, not of the tree

⇒ Neither the card's 33/3/46/18 nor this lane's numbers are "the" numbers. That is worth more than a corrected table, because it means any future card quoting a specific grouping shape is quoting an artefact of its fixture's row order. In both shapes the document contained no 186, no partial, no loaded — the defect measured directly rather than inferred.

The two conditions are the design decision, and they are right

Two triggers of deliberately different strength, each matched to wording it can honestly support:

  1. A real total (resolvedTotalMatching exceeds rows in hand) → partial as a fact, stated with both numbers.
  2. No total, but a full window came back → can only ever say "may", because a result set that exactly fills the window trips it too. plugin-list's own footer draws the same inference the same way.

And hasInlineData gates condition 2 out, with the correct reason: inline rows are not a page — nothing was asked for and nothing withheld. A result set that fits leaves the grid silent, "and that silence is what makes the marker mean something when it does appear."

⭐ Reusing resolvedTotalMatching rather than re-spelling the externalManualPagination conditional is the detail I'd flag as load-bearing, and the comment names the precedent: "two copies of it is how one of them got missed in #4464." That is the same two-sites failure #7179 hit, avoided pre-emptively rather than rediscovered.

Tier not engaged — verified, not asserted

GroupRow gains two optional React props; @objectstack/spec is untouched and no schema key is added. So this does not route through CONTRACT_REVIEW_TIER, whose quota is exhausted with two build-ready cards already starved behind it. Getting the disclosure in without spending that slot is what made shipping possible at all.

The defaults are also correctly placed in GRID_DEFAULT_TRANSLATIONS, with the reason stated: a provider-less host must not see a raw key, "and this is a statement about whether the numbers on screen are true."

Ablation: leg B is the one that counts

Predictions written to disk before either leg ran; both matched exactly. Leg A (groupingIsPartial = false) → 8 failed / 5 passed. Leg B (marker made unconditional) → 3 failed / 10 passed, red on the controls only — fits-in-one-page, inline rows, short window.

That second leg is what makes the eight positive pins worth anything: it proves the gate is real rather than that a marker renders. A disclosure that always appears would have passed every positive assertion.

jsdom caveat closed by measurement, not by assumption

My order warned that jsdom applies media queries regardless of innerWidth. Rather than running Chromium reflexively or ignoring the warning, the lane established the warning does not apply — nothing in this disclosure is width-dependent, and grouping never reaches the one width-driven branch (useCardView && data.length > 0 && !isGrouped). Pin 13 asserts that gate in source rather than trusting a jsdom reading. Correct handling: prove the hazard is out of scope, don't just avoid it.

check:doc-snippets (exit 2, its own text: "This is I could not run, NOT I ran and found errors") and check:readme-exports were declared NOT MEASURED, with the diff measured to add zero fenced blocks. Right on both counts.

One finding escalated rather than filed

useServerPagination = !hasInlineData && !isGrouped — I confirmed this independently at ObjectGrid.tsx:3224 on main, and manualPaginationOn excludes grouped too. So a grouped grid that owns its fetch has no server pager at all; its grouped pager pages groups in memory.

⇒ Records past the first $top window are unreachable, not merely undisclosed. No amount of disclosure fixes that, and it makes this PR's notice more necessary rather than less — it now tells a user about data they have no way to reach. Carried to #5560 as a strengthening argument for server-side grouping, which is exactly where it belongs: inside this card's own durable-fix half, not as a new card.

Armed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page - #7226

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure
Sep 1, 2026
Merged

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page#7226
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #7189 — the disclosure half only.

A grouped grid now states, where the group counts are, that it grouped a page.

The card's numbers were stale, so here is today's main

objectui#7179 landed as a6d8b8d44 after the card was written, and it changed
what the card measures: the projection now carries the grouping field, so the
groups resolve and the (empty) collapse is gone. The card's table (four
groups for five units, 33 / 3 / 46 / 18) cannot be reproduced and nothing
here was built against it.

Re-measured on a6d8b8d44 — 186 records over five business units sized
86 / 61 / 31 / 7 / 1, $top: 100, $select confirmed to carry
business_unit:

fixture orderinggroup headers renderedcountsstore holds
contiguous2 of 586, 1486, 61, 31, 7, 1
interleaved5 of 531, 31, 30, 1, 786, 61, 31, 7, 1

In both, the document contained no 186, no "partial", no "loaded". Which
shape you see is a property of row ordering, not of the tree — the defect is
the same one either way, and the disclosure has to be true of both.

The paging footer the card quotes is not the grid's: Showing first N records. More data may be available. lives in
packages/plugin-list/src/ListView.tsx (list.dataLimitReached), a different
component in a different package. A standalone grouped grid renders no footer
at all.

What this adds

useGroupedData's grouping and aggregation algorithm is untouched — it is
correct for what it does, and the counts above are still page slices
afterwards. What was missing is any statement that client-side grouping is
what you are looking at.

  • a short Partial marker beside every group count, at every nesting
    depth
    , carrying the whole sentence as its title and its accessible name;
  • one line directly above the group list, inside the grouped region rather
    than in the paging footer, which is not a statement about what was grouped.

Is a result-set total reachable where the group headers render? Yes

resolvedTotalMatching (ObjectGrid.tsx:3769) is in scope at the grouped render
site (~4688 / ~4755), and it is the same ONE derived value the pager and both
bulk-bar sites already read — deliberately not re-spelled here, per the note
objectui#4464 left on it. It resolves from the grid's own result.total when
the grid owns the fetch, and from a host's rowCount when a host does. Both
paths are pinned.

So the strong wording is supported, and the trigger never outruns what the
component knows:

conditionwording
total known and greater than rows in hand"Grouped over the first 100 of 186 records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here."
no total, window came back full"Grouped over the 100 records loaded. More may match this view…" — may only say may, the same inference plugin-list's own footer draws when no total is known
rows handed in inlinenothing — nothing was asked for and nothing was withheld
result set fits the pagenothing

That last row is the point of the whole thing: a marker that is always on says
nothing.

Deliberately NOT here

Server-side grouping. It is the card's own preferred durable fix, an
API-surface decision open on #5560, and out of scope for this branch: nothing
builds toward it, no flag anticipates it, the fetch is unchanged. That half of
#7189 stays open.

No metadata contract was widened

No schema key was added — the condition is derived from data the grid already
holds. GroupRow (exported from this package) gains two optional props,
partialLabel and partialTitle; @objectstack/spec is untouched.

Tests — 13 new pins, and an ablation in both directions

packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx.
Prediction was written before either leg ran and both matched exactly.

legmutationpredictedobserved
AgroupingIsPartial = false (disclosure removed)8 failed / 5 passed8 failed / 5 passed
BgroupingIsPartial = isGrouped (marker unconditional)3 failed / 10 passed3 failed / 10 passed

Leg B goes red on the controls only — fits-in-one-page, inline rows,
short window — which is what makes the eight positive pins worth anything.

Each leg: mutation proved on disk by injected-marker line count (1) and
removed-expression line count (0) and a blob hash moved off
HEAD:packages/plugin-grid/src/ObjectGrid.tsx
(eaab121121273aea4e6304d96d82f6fa0da6b06d); restore by
git checkout HEAD -- ABSOLUTE_PATH and proved by stategit diff HEAD,
git diff --cached and git status --short all empty and the blob hash back
to HEAD's — with the restore trapped on EXIT INT TERM. No rebuild was needed
or performed: vitest.config.mts:288 aliases @object-ui/plugin-grid to
packages/plugin-grid/src, verified by reading the alias table.

jsdom caveat, closed rather than assumed. jsdom applies CSS media-query
rules irrespective of innerWidth, so a width-dependent reading in it is not a
measurement. This disclosure is not width-dependent: it carries no responsive
visibility utility, and grouping never reaches the width-driven branch —
if (useCardView && data.length &gt; 0 && !isGrouped). The last pin asserts
that gate in source rather than trusting the reading.

Verification, all at 1d8fd9e65 (the final commit)

  • pnpm exec vitest run packages/i18n/ packages/plugin-grid/166 files,
    1912 tests, all passed
    . Includes all-locales-key-parity, which owns the
    ten-pack key sets the three new grid.grouping.* strings had to satisfy.
  • turbo run type-check --filter @object-ui/plugin-grid --filter @object-ui/i18n — 15/15 tasks. tsc -p tsconfig.test.json --listFiles
    confirms the new test file and both edited sources are in that program, so
    "typecheck clean" actually covers them.
  • Consumer sweep, direction dependents of @object-ui/plugin-grid (named
    explicitly, because the ... prefix means opposite things in pnpm and
    turbo): app-shell, console, plugin-designer, plugin-report, plugin-view,
    plugin-list — turbo run type-check, 41/41 tasks.
  • check:i18n-keys, check:i18n-drift (0 en values changed, 3 keys added),
    check:control-bytes, check:doc-types, check:doc-fences,
    check:changeset-presence — all exit 0 at 1d8fd9e65.
  • eslint, plain form, on all 13 changed .ts/.tsx files: 0 errors; the
    new test file is 0 errors / 0 warnings. Type-aware linting is not enabled in
    eslint.config.js (no parserOptions.project), so this diff cannot move a
    verdict in a file it did not touch — the narrowing is a measurement, not a
    skip. The repo-wide scan is CI's.
  • NOT MEASURED locally, declared:check:doc-snippets and
    check:readme-exports. Both exit on an explicit precondition — 21
    packages unbuilt in a fresh worktree — and both judge only fenced code
    blocks. This diff adds zero fenced blocks to any document (measured:
    zero added lines carrying a fence). CI builds and runs them.

Changeset

@object-ui/plugin-grid and @object-ui/i18n, both minor. The docs edits
ride along in the same PR, so no separate empty-frontmatter changeset is owed —
that form is for a docs-only change, where a patch would falsely claim a
released package moved.


Generated by Claude Code

… it grouped a page
`useGroupedData` buckets the rows the browser already holds and computes every
per-group aggregate from that same array, so both the set of groups and every
number in a group header are properties of the fetched page, not of the query.
The algorithm is correct for what it does and is untouched here; what was
missing is any statement that client-side grouping is what you are looking at.
Measured on today's `main` (a6d8b8d) over a 186-record store distributed
86/61/31/7/1 across five business units with a 100-row page: contiguous rows
rendered TWO group headers (86, 14) with three units absent from the screen
entirely; interleaved rows rendered all five with every count a page slice
(31/31/30/1/7). The document contained no "186", no "partial", no "loaded".
The disclosure goes where the authoritative-looking number is: a short
`Partial` marker beside every group count at every depth, carrying the whole
sentence as its title and accessible name, plus one line directly above the
group list inside the grouped region — not in the paging footer, which is not
a statement about what was grouped.
The trigger never outruns what the component knows. A real match total
(`resolvedTotalMatching`, the one derived value the pager and both bulk-bar
sites already read) states the fact with both numbers; a full window with no
total may only say "more may match", the same inference plugin-list's own
footer draws. Inline rows are not a page and are never marked, and a grouped
grid whose result set fits in one page shows nothing at all.
Server-side grouping is deliberately out of scope — an API-surface decision
still open on objectui#5560. Nothing here changes the fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Removes the four `no-explicit-any` warnings the new file carried; the row
shape is a real interface and the find params read through
`Record<string, unknown>`. No assertion changed — still 13 passed (13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation plugin tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-BsrOgisl.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)208.80KB56.56KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.47KB27.66KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM

Verified over git: clean against origin/main5015fcf52 (merge-tree --write-tree exit 0), changeset present, 17 files, all ten locale packs.

My assumption was wrong again, and this one changes the card's framing

The order said the footer already discloses paging and the problem is only where it says so. False:

the footer the card cites is NOT the grid's — Showing first N records. More data may be available. lives in plugin-list/src/ListView.tsx as list.dataLimitReached, a different component in a different package, and a standalone grouped grid renders no footer at all.

So the card's premise — "the footer does disclose it, but the group header is the number people trust" — understates the defect. In the standalone case there was no disclosure anywhere, not a badly-placed one. Fourth PM premise falsified by a lane this shift.

The conclusion survived the falsification, which is the useful part: a total is reachable at the group-header render site via resolvedTotalMatching, so the strong "first 100 of 186" wording is supported rather than assumed.

Re-measuring rather than reproducing the stale table was the right call

The order warned the card's figures predate #7179. What came back is sharper than "the numbers changed":

two group headers (86, 14) with three units absent when rows were contiguous; five headers reading 31/31/30/1/7 when interleaved — which shape appears is a property of row ordering, not of the tree

⇒ Neither the card's 33/3/46/18 nor this lane's numbers are "the" numbers. That is worth more than a corrected table, because it means any future card quoting a specific grouping shape is quoting an artefact of its fixture's row order. In both shapes the document contained no 186, no partial, no loaded — the defect measured directly rather than inferred.

The two conditions are the design decision, and they are right

Two triggers of deliberately different strength, each matched to wording it can honestly support:

  1. A real total (resolvedTotalMatching exceeds rows in hand) → partial as a fact, stated with both numbers.
  2. No total, but a full window came back → can only ever say "may", because a result set that exactly fills the window trips it too. plugin-list's own footer draws the same inference the same way.

And hasInlineData gates condition 2 out, with the correct reason: inline rows are not a page — nothing was asked for and nothing withheld. A result set that fits leaves the grid silent, "and that silence is what makes the marker mean something when it does appear."

⭐ Reusing resolvedTotalMatching rather than re-spelling the externalManualPagination conditional is the detail I'd flag as load-bearing, and the comment names the precedent: "two copies of it is how one of them got missed in #4464." That is the same two-sites failure #7179 hit, avoided pre-emptively rather than rediscovered.

Tier not engaged — verified, not asserted

GroupRow gains two optional React props; @objectstack/spec is untouched and no schema key is added. So this does not route through CONTRACT_REVIEW_TIER, whose quota is exhausted with two build-ready cards already starved behind it. Getting the disclosure in without spending that slot is what made shipping possible at all.

The defaults are also correctly placed in GRID_DEFAULT_TRANSLATIONS, with the reason stated: a provider-less host must not see a raw key, "and this is a statement about whether the numbers on screen are true."

Ablation: leg B is the one that counts

Predictions written to disk before either leg ran; both matched exactly. Leg A (groupingIsPartial = false) → 8 failed / 5 passed. Leg B (marker made unconditional) → 3 failed / 10 passed, red on the controls only — fits-in-one-page, inline rows, short window.

That second leg is what makes the eight positive pins worth anything: it proves the gate is real rather than that a marker renders. A disclosure that always appears would have passed every positive assertion.

jsdom caveat closed by measurement, not by assumption

My order warned that jsdom applies media queries regardless of innerWidth. Rather than running Chromium reflexively or ignoring the warning, the lane established the warning does not apply — nothing in this disclosure is width-dependent, and grouping never reaches the one width-driven branch (useCardView && data.length > 0 && !isGrouped). Pin 13 asserts that gate in source rather than trusting a jsdom reading. Correct handling: prove the hazard is out of scope, don't just avoid it.

check:doc-snippets (exit 2, its own text: "This is I could not run, NOT I ran and found errors") and check:readme-exports were declared NOT MEASURED, with the diff measured to add zero fenced blocks. Right on both counts.

One finding escalated rather than filed

useServerPagination = !hasInlineData && !isGrouped — I confirmed this independently at ObjectGrid.tsx:3224 on main, and manualPaginationOn excludes grouped too. So a grouped grid that owns its fetch has no server pager at all; its grouped pager pages groups in memory.

⇒ Records past the first $top window are unreachable, not merely undisclosed. No amount of disclosure fixes that, and it makes this PR's notice more necessary rather than less — it now tells a user about data they have no way to reach. Carried to #5560 as a strengthening argument for server-side grouping, which is exactly where it belongs: inside this card's own durable-fix half, not as a new card.

Armed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page - #7226

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure
Sep 1, 2026
Merged

feat(grid,i18n): a grouped grid states, beside the group counts, that it grouped a page#7226
os-warren merged 2 commits into
mainfrom
claude/issue-7189-grouped-grid-partial-disclosure

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #7189 — the disclosure half only.

A grouped grid now states, where the group counts are, that it grouped a page.

The card's numbers were stale, so here is today's main

objectui#7179 landed as a6d8b8d44 after the card was written, and it changed
what the card measures: the projection now carries the grouping field, so the
groups resolve and the (empty) collapse is gone. The card's table (four
groups for five units, 33 / 3 / 46 / 18) cannot be reproduced and nothing
here was built against it.

Re-measured on a6d8b8d44 — 186 records over five business units sized
86 / 61 / 31 / 7 / 1, $top: 100, $select confirmed to carry
business_unit:

fixture orderinggroup headers renderedcountsstore holds
contiguous2 of 586, 1486, 61, 31, 7, 1
interleaved5 of 531, 31, 30, 1, 786, 61, 31, 7, 1

In both, the document contained no 186, no "partial", no "loaded". Which
shape you see is a property of row ordering, not of the tree — the defect is
the same one either way, and the disclosure has to be true of both.

The paging footer the card quotes is not the grid's: Showing first N records. More data may be available. lives in
packages/plugin-list/src/ListView.tsx (list.dataLimitReached), a different
component in a different package. A standalone grouped grid renders no footer
at all.

What this adds

useGroupedData's grouping and aggregation algorithm is untouched — it is
correct for what it does, and the counts above are still page slices
afterwards. What was missing is any statement that client-side grouping is
what you are looking at.

  • a short Partial marker beside every group count, at every nesting
    depth
    , carrying the whole sentence as its title and its accessible name;
  • one line directly above the group list, inside the grouped region rather
    than in the paging footer, which is not a statement about what was grouped.

Is a result-set total reachable where the group headers render? Yes

resolvedTotalMatching (ObjectGrid.tsx:3769) is in scope at the grouped render
site (~4688 / ~4755), and it is the same ONE derived value the pager and both
bulk-bar sites already read — deliberately not re-spelled here, per the note
objectui#4464 left on it. It resolves from the grid's own result.total when
the grid owns the fetch, and from a host's rowCount when a host does. Both
paths are pinned.

So the strong wording is supported, and the trigger never outruns what the
component knows:

conditionwording
total known and greater than rows in hand"Grouped over the first 100 of 186 records. Group counts are page-scoped, and a group whose records all fall beyond the loaded rows is missing here."
no total, window came back full"Grouped over the 100 records loaded. More may match this view…" — may only say may, the same inference plugin-list's own footer draws when no total is known
rows handed in inlinenothing — nothing was asked for and nothing was withheld
result set fits the pagenothing

That last row is the point of the whole thing: a marker that is always on says
nothing.

Deliberately NOT here

Server-side grouping. It is the card's own preferred durable fix, an
API-surface decision open on #5560, and out of scope for this branch: nothing
builds toward it, no flag anticipates it, the fetch is unchanged. That half of
#7189 stays open.

No metadata contract was widened

No schema key was added — the condition is derived from data the grid already
holds. GroupRow (exported from this package) gains two optional props,
partialLabel and partialTitle; @objectstack/spec is untouched.

Tests — 13 new pins, and an ablation in both directions

packages/plugin-grid/src/__tests__/groupedPartialDisclosure-7189.test.tsx.
Prediction was written before either leg ran and both matched exactly.

legmutationpredictedobserved
AgroupingIsPartial = false (disclosure removed)8 failed / 5 passed8 failed / 5 passed
BgroupingIsPartial = isGrouped (marker unconditional)3 failed / 10 passed3 failed / 10 passed

Leg B goes red on the controls only — fits-in-one-page, inline rows,
short window — which is what makes the eight positive pins worth anything.

Each leg: mutation proved on disk by injected-marker line count (1) and
removed-expression line count (0) and a blob hash moved off
HEAD:packages/plugin-grid/src/ObjectGrid.tsx
(eaab121121273aea4e6304d96d82f6fa0da6b06d); restore by
git checkout HEAD -- ABSOLUTE_PATH and proved by stategit diff HEAD,
git diff --cached and git status --short all empty and the blob hash back
to HEAD's — with the restore trapped on EXIT INT TERM. No rebuild was needed
or performed: vitest.config.mts:288 aliases @object-ui/plugin-grid to
packages/plugin-grid/src, verified by reading the alias table.

jsdom caveat, closed rather than assumed. jsdom applies CSS media-query
rules irrespective of innerWidth, so a width-dependent reading in it is not a
measurement. This disclosure is not width-dependent: it carries no responsive
visibility utility, and grouping never reaches the width-driven branch —
if (useCardView && data.length &gt; 0 && !isGrouped). The last pin asserts
that gate in source rather than trusting the reading.

Verification, all at 1d8fd9e65 (the final commit)

  • pnpm exec vitest run packages/i18n/ packages/plugin-grid/166 files,
    1912 tests, all passed
    . Includes all-locales-key-parity, which owns the
    ten-pack key sets the three new grid.grouping.* strings had to satisfy.
  • turbo run type-check --filter @object-ui/plugin-grid --filter @object-ui/i18n — 15/15 tasks. tsc -p tsconfig.test.json --listFiles
    confirms the new test file and both edited sources are in that program, so
    "typecheck clean" actually covers them.
  • Consumer sweep, direction dependents of @object-ui/plugin-grid (named
    explicitly, because the ... prefix means opposite things in pnpm and
    turbo): app-shell, console, plugin-designer, plugin-report, plugin-view,
    plugin-list — turbo run type-check, 41/41 tasks.
  • check:i18n-keys, check:i18n-drift (0 en values changed, 3 keys added),
    check:control-bytes, check:doc-types, check:doc-fences,
    check:changeset-presence — all exit 0 at 1d8fd9e65.
  • eslint, plain form, on all 13 changed .ts/.tsx files: 0 errors; the
    new test file is 0 errors / 0 warnings. Type-aware linting is not enabled in
    eslint.config.js (no parserOptions.project), so this diff cannot move a
    verdict in a file it did not touch — the narrowing is a measurement, not a
    skip. The repo-wide scan is CI's.
  • NOT MEASURED locally, declared:check:doc-snippets and
    check:readme-exports. Both exit on an explicit precondition — 21
    packages unbuilt in a fresh worktree — and both judge only fenced code
    blocks. This diff adds zero fenced blocks to any document (measured:
    zero added lines carrying a fence). CI builds and runs them.

Changeset

@object-ui/plugin-grid and @object-ui/i18n, both minor. The docs edits
ride along in the same PR, so no separate empty-frontmatter changeset is owed —
that form is for a docs-only change, where a patch would falsely claim a
released package moved.


Generated by Claude Code

… it grouped a page
`useGroupedData` buckets the rows the browser already holds and computes every
per-group aggregate from that same array, so both the set of groups and every
number in a group header are properties of the fetched page, not of the query.
The algorithm is correct for what it does and is untouched here; what was
missing is any statement that client-side grouping is what you are looking at.
Measured on today's `main` (a6d8b8d) over a 186-record store distributed
86/61/31/7/1 across five business units with a 100-row page: contiguous rows
rendered TWO group headers (86, 14) with three units absent from the screen
entirely; interleaved rows rendered all five with every count a page slice
(31/31/30/1/7). The document contained no "186", no "partial", no "loaded".
The disclosure goes where the authoritative-looking number is: a short
`Partial` marker beside every group count at every depth, carrying the whole
sentence as its title and accessible name, plus one line directly above the
group list inside the grouped region — not in the paging footer, which is not
a statement about what was grouped.
The trigger never outruns what the component knows. A real match total
(`resolvedTotalMatching`, the one derived value the pager and both bulk-bar
sites already read) states the fact with both numbers; a full window with no
total may only say "more may match", the same inference plugin-list's own
footer draws. Inline rows are not a page and are never marked, and a grouped
grid whose result set fits in one page shows nothing at all.
Server-side grouping is deliberately out of scope — an API-surface decision
still open on objectui#5560. Nothing here changes the fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Removes the four `no-explicit-any` warnings the new file carried; the row
shape is a real interface and the find params read through
`Record<string, unknown>`. No assertion changed — still 13 passed (13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation plugin tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3160.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-BsrOgisl.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)514.13KB117.19KB
core (index.js)5.55KB2.23KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.65KB63.91KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)208.80KB56.56KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.47KB27.66KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review — ACCEPT. Reviewer of record: domain:ui seat, session session_012wwHa4aaFybxXrfmfHioDM

Verified over git: clean against origin/main5015fcf52 (merge-tree --write-tree exit 0), changeset present, 17 files, all ten locale packs.

My assumption was wrong again, and this one changes the card's framing

The order said the footer already discloses paging and the problem is only where it says so. False:

the footer the card cites is NOT the grid's — Showing first N records. More data may be available. lives in plugin-list/src/ListView.tsx as list.dataLimitReached, a different component in a different package, and a standalone grouped grid renders no footer at all.

So the card's premise — "the footer does disclose it, but the group header is the number people trust" — understates the defect. In the standalone case there was no disclosure anywhere, not a badly-placed one. Fourth PM premise falsified by a lane this shift.

The conclusion survived the falsification, which is the useful part: a total is reachable at the group-header render site via resolvedTotalMatching, so the strong "first 100 of 186" wording is supported rather than assumed.

Re-measuring rather than reproducing the stale table was the right call

The order warned the card's figures predate #7179. What came back is sharper than "the numbers changed":

two group headers (86, 14) with three units absent when rows were contiguous; five headers reading 31/31/30/1/7 when interleaved — which shape appears is a property of row ordering, not of the tree

⇒ Neither the card's 33/3/46/18 nor this lane's numbers are "the" numbers. That is worth more than a corrected table, because it means any future card quoting a specific grouping shape is quoting an artefact of its fixture's row order. In both shapes the document contained no 186, no partial, no loaded — the defect measured directly rather than inferred.

The two conditions are the design decision, and they are right

Two triggers of deliberately different strength, each matched to wording it can honestly support:

  1. A real total (resolvedTotalMatching exceeds rows in hand) → partial as a fact, stated with both numbers.
  2. No total, but a full window came back → can only ever say "may", because a result set that exactly fills the window trips it too. plugin-list's own footer draws the same inference the same way.

And hasInlineData gates condition 2 out, with the correct reason: inline rows are not a page — nothing was asked for and nothing withheld. A result set that fits leaves the grid silent, "and that silence is what makes the marker mean something when it does appear."

⭐ Reusing resolvedTotalMatching rather than re-spelling the externalManualPagination conditional is the detail I'd flag as load-bearing, and the comment names the precedent: "two copies of it is how one of them got missed in #4464." That is the same two-sites failure #7179 hit, avoided pre-emptively rather than rediscovered.

Tier not engaged — verified, not asserted

GroupRow gains two optional React props; @objectstack/spec is untouched and no schema key is added. So this does not route through CONTRACT_REVIEW_TIER, whose quota is exhausted with two build-ready cards already starved behind it. Getting the disclosure in without spending that slot is what made shipping possible at all.

The defaults are also correctly placed in GRID_DEFAULT_TRANSLATIONS, with the reason stated: a provider-less host must not see a raw key, "and this is a statement about whether the numbers on screen are true."

Ablation: leg B is the one that counts

Predictions written to disk before either leg ran; both matched exactly. Leg A (groupingIsPartial = false) → 8 failed / 5 passed. Leg B (marker made unconditional) → 3 failed / 10 passed, red on the controls only — fits-in-one-page, inline rows, short window.

That second leg is what makes the eight positive pins worth anything: it proves the gate is real rather than that a marker renders. A disclosure that always appears would have passed every positive assertion.

jsdom caveat closed by measurement, not by assumption

My order warned that jsdom applies media queries regardless of innerWidth. Rather than running Chromium reflexively or ignoring the warning, the lane established the warning does not apply — nothing in this disclosure is width-dependent, and grouping never reaches the one width-driven branch (useCardView && data.length > 0 && !isGrouped). Pin 13 asserts that gate in source rather than trusting a jsdom reading. Correct handling: prove the hazard is out of scope, don't just avoid it.

check:doc-snippets (exit 2, its own text: "This is I could not run, NOT I ran and found errors") and check:readme-exports were declared NOT MEASURED, with the diff measured to add zero fenced blocks. Right on both counts.

One finding escalated rather than filed

useServerPagination = !hasInlineData && !isGrouped — I confirmed this independently at ObjectGrid.tsx:3224 on main, and manualPaginationOn excludes grouped too. So a grouped grid that owns its fetch has no server pager at all; its grouped pager pages groups in memory.

⇒ Records past the first $top window are unreachable, not merely undisclosed. No amount of disclosure fixes that, and it makes this PR's notice more necessary rather than less — it now tells a user about data they have no way to reach. Carried to #5560 as a strengthening argument for server-side grouping, which is exactly where it belongs: inside this card's own durable-fix half, not as a new card.

Armed.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude