fix(grid,list,core): union a grid's grouping fields into the query projection - #7214

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection
Sep 1, 2026
Merged

fix(grid,list,core): union a grid's grouping fields into the query projection#7214
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7179

A grid view declaring grouping on a field absent from its columns rendered one group
labelled (empty) holding every row. $select was built from columns alone, so the
grouping field was never requested and was undefined on every row by the time grouping
ran. The grouping fields are now unioned into the projection — and into $expand — at
every site the projection is built.

What the dispatch assumed, and what the code actually says

The projection is built at TWO sites, not one. Both are on the grid's path and they
are independent, so a fix landing on one leaves the other blind:

sitewhen it runs
packages/plugin-grid/src/ObjectGrid.tsxgetSelectFields()the grid fetches for itself
packages/plugin-list/src/ListView.tsx — the selectFields IIFEListView fetches and hands the rows down to the grid

Both are fixed, and each has its own regression file so neither can silently regress
back to the other's behaviour.

"Three sibling adapters each union their groupByField" is not what the code does.
plugin-kanban, plugin-gantt and plugin-timeline contain no projection builder at
all
— a census of $select across their src/ returns zero hits. All three are served
by one shared arm, collectViewFields in ListView.tsx, which reads groupByField /
groupField / groupBy as plain strings. The grid was the only projection-blind path of
the four not because it lacked an adapter, but because it spells the same intent
differently: grouping.fields[], an array of objects, matching none of those candidate
keys. The correction stands — those three files are untouched here.

grouping.fields[] entries carry a plain string, confirmed against the spec.
GroupingFieldSchema in @objectstack/spec@17.2.0 is a $strict object of exactly
{ field: z.ZodString, order, collapsed }. No expression form, no nested-path form. A
bare-string shorthand is refused, deliberately: the schema does not accept it, and
useGroupedData reads f.field off each entry, so a bare string groups by undefined
no matter what the projection asks for. Reading it anyway would be the lenient
renderer-side alias AGENTS.md #0.1 forbids.

populate IS part of the fix. A select that fetches a bare foreign key without
expanding it buckets by raw id instead of by name — a different wrong answer, not a fix.
The expandFields memo in ListView.tsx already records this exact failure for kanban
in its own comment ("list view shows 'Initech Solutions' but kanban used to show
'8UY9zHWBfjYjYor4'"), and a grid grouped by a lookup landed in it. Grouping fields are
now unioned into $expand at both sites, pinned by its own test on each side.

The hazard, and why the union is gated

An unguarded union would have been strictly worse than the bug. A grouping.fields[]
entry has never been through column validation — that is the card's whole premise — and
some backends answer an unknown $select key with an empty result set rather than
ignoring it. Unioned raw, a grouping field naming a field the object does not declare
turns one (empty) group holding every row into zero rows, equally silently.

So grouping fields land on the speculative, gated side of both builders, never
alongside the known-valid columns:

  • Unknown-key gateisProjectableField in ObjectGrid, addSpeculative in
    ListView. A grouping field the object does not declare is dropped, and the rest of
    the projection survives intact (pinned in both directions: the unknown key is absent
    and the columns are still there).
  • Field-level securitypassesProjectionGate in ObjectGrid, and a new
    addGroupingField wrapper in ListView. A grouping field can name a denied field
    exactly as a column can, and the projection is the half that goes on the wire, so
    unioning after the FLS filter would have reopened objectui#6898 through a new door.
    projectionFls-6898.test.tsx is run here as a regression control.
  • $expand needs no unknown-key gate and deliberately does not get one:
    buildExpandFields returns a subset of the object's declared reference-bearing
    fields, so an unknown or non-relational grouping field is dropped structurally. Pinned
    anyway, so a later refactor cannot make that accidental.

ObjectGrid's fetch effect gains a groupingProjectionKey dep — a content key over the
grouping field names only. Grouping is runtime-mutable (the toolbar popover rewrites
it), so without it, switching the grouping field would leave the query asking for the old
one and the new grouping would read undefined on every row — the same (empty) bucket,
reachable a second way. Names only, because order and collapsed are render-time
concerns: collapsing a group must not cost a round trip.

Ablation

Direction and counts predicted before running; each leg's mutation proven on disk by
anchored marker count and blob hash; each restore proven by state (git diff HEAD,
git diff --cached, git status --short all empty) using git checkout HEAD -- with an
absolute path, never the bare form. The passing count is asserted alongside the red so a
collapsed population cannot pass for a clean run. Restore is trapped on EXIT INT TERM.

legmutationpredictedobserved
AObjectGrid projection union removed5 failed / 47 passed5 failed / 47 passed
BListView projection union removed4 failed / 48 passed4 failed / 48 passed
Cboth $expand augmentations removed2 failed / 50 passed2 failed / 50 passed

All three matched exactly. Leg A leaves the ListView file fully green and leg B leaves
the ObjectGrid file fully green — which is the measurement that shows the two sites are
genuinely independent, and that a one-site fix would have shipped looking correct.

Marker evidence per leg, e.g. leg A: removed-token 1 -> 0, injected-token 0 -> 1,
blob 0a3f9248 to 488b47ab. Restore confirmed both files byte-identical to their HEAD
blobs.

Verification

All runs below are on the final commit, 8006103ba.

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/core/
    272 files / 3846 tests passed, exit 0.
  • pnpm exec vitest run on the four suites that matter (the two new projection files,
    the new core unit file, and projectionFls-6898.test.tsx as regression control) —
    4 files / 52 tests passed, exit 0. File count asserted as the control that the
    right suites ran.
  • pnpm lint (the whole farm, eslint . --no-inline-config) — 47/47 tasks
    successful
    , exit 0. Not narrowed.
  • pnpm exec turbo run type-check for the three packages — 16/16 tasks successful,
    exit 0. Each package runs tsc --noEmit && tsc -p tsconfig.test.json, and all three
    new test files were confirmed present in the test program via --listFiles, so the
    green covers the tests and not just the sources.
  • Gates derived by hand from this repo's package.json and workflows (this repo has no
    scripts/pm/, and objectstack's dispatch-gates script answers only about its own
    tree): check:control-bytes, check:self-import, check:esm-specifiers,
    check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:side-effects-array, check:entry-guard, check-changeset-presence,
    check-changeset-no-majorall exit 0, each read from the gate's own printed
    verdict line rather than a bare exit status.
  • NOT MEASURED, reported as neither colour:check:readme-exports and
    check:eager-closure both require a full monorepo build this worktree does not have.
    Each says so itself — readme-exports reports "the population COLLAPSED, this run
    proves nothing" with 24 packages unbuilt, and eager-closure reports a missing
    apps/console/dist/eager-closure.json and calls itself "a broken gauge, not a
    sensitive gate". Neither reads any file in this diff (no README and no entry manifest
    is touched). CI builds before it runs them.

Exit codes throughout were captured by redirecting to a file first, never read after a
pipe.

Scope

The (empty) guard in useGroupedData.ts is untouched — it is correct for a
genuinely empty value and cannot distinguish it from a field that was never fetched. The
defect was upstream of it. sort is untouched, per the reporter recording it as
unmeasured rather than cleared. The kanban, gantt and timeline paths are untouched.

Out of scope, found while measuring

Not fixed here, and not yet filed — this seat's GitHub search returned
API rate limit already exceeded, so the mandatory duplicate check could not be run and
filing blind would risk duplicates. Handed to the PM in the structured report:

  1. A null entry in grouping.fields[] crashes the whole grid.ObjectGrid's
    groupValueFormatter memo dereferences gf.field with no null guard, throwing a
    TypeError before any projection is built. Confirmed pre-existing at this branch's
    base commit 4f596e01a. A different defect class from this card's silent wrong
    answer, so it is filed rather than ridden. The new harvester handles null correctly
    and that is pinned directly in the core unit test, which needs no grid to mount.
  2. $expand carries no FLS gate at either site.$select is FLS-gated on both
    paths, but buildExpandFields is handed a column list that was never filtered, so a
    denied lookup column is still expanded. Adjacent to objectui#6898 and affects columns
    generally, not grouping, so fixing it here would widen the review surface past this
    card.
  3. ListView's speculative view bindings are not FLS-gated. The kanban / gantt /
    timeline / calendar / gallery fields routed through addSpeculative are checked
    against the known-field set but never against checkField. The grouping field added
    by this PR is FLS-gated, so this PR does not extend the gap — it leaves an
    asymmetry worth closing deliberately rather than as a rider.

Generated by Claude Code


Generated by Claude Code

…ojection
A grid view declaring `grouping` on a field absent from its `columns` rendered
one group labelled `(empty)` holding every row. `$select` was built from the
view's `columns` alone, so the grouping field was never requested and was
`undefined` on every row by the time grouping ran.
The fix is in the projection, at both sites it is built (`ObjectGrid` when it
fetches for itself, `ListView` when it fetches for the grid), plus `$expand` so
a lookup grouping field groups by name rather than by raw foreign key.
The union is gated: grouping fields are intersected with the object's declared
fields and pass the same FLS gate as columns, because an unknown `$select` key
zeroes the whole list on backends that reject rather than ignore it.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3158.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D1fw90cR.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)207.50KB56.13KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.45KB27.64KB
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 at zero API quota (the seat is rate-limited): merge is clean against origin/main39d69ad53 (git merge-tree --write-tree exit 0), changeset present, 8 files.

The mid-flight correction was load-bearing, and the ablation is what proves it

My dispatch order asserted a single$select build site. That was wrong, and I sent the correction after grepping — but a correction is only a claim until it is measured. Legs A and B are the measurement:

leg A, ObjectGrid projection union removed → 5 failed / 47 passed. Leg A left the ListView file fully green.
leg B, ListView projection union removed → 4 failed / 48 passed. Leg B left the ObjectGrid file fully green.

That pair is the finding, not a formality: each site fails alone, so a one-site fix would have shipped with a green suite and a still-broken surface. All three legs matched their predicted counts exactly, with the PASSING count asserted alongside each red — so a collapsed population could not have passed for a clean run.

Both hazards I flagged are closed, and the reasoning is right

  • Unknown-key → zeroed list.isProjectableField intersects against the declared fields before anything reaches $select. The comment states the stake correctly: an unguarded union would have traded one (empty) group holding 186 rows for zero rows, equally silently — strictly worse, because the current bug at least shows the data.
  • FLS (objectui#6898).passesProjectionGate on the grid half, checkField on the list half, both applied before the union rather than after. The regression suite ran green as a control.
  • $expand was the right call to make unguarded, and for the stated structural reason — buildExpandFields returns a subset of the object's declared reference-bearing fields, so an unknown or non-relational entry is dropped by construction.

Three things I did not ask for and would have missed:

  1. $expand at all. My ZONE 2 asked whether populate was part of the fix and said to measure it. It is: without it a lookup grouping buckets by raw foreign key (8UY9zHWBfjYjYor4) instead of by name — better than one (empty) bucket, still wrong.
  2. The expandColumns ?? undefined conditional. Passing an array unconditionally would have narrowed the no-columns case from "every relation" to "the grouping fields alone". That is a regression the obvious patch introduces silently.
  3. groupingProjectionKey as a content key over the field NAMES only. Naming schema.grouping directly would have re-issued the query every render (hosts spread a fresh literal — the census(finding): three more renderer fetch effects key on a memoised object identity, outside objectui#6592's dataConfig family #6697 identity-churn storm), and keying the whole block would have cost a round trip every time a user collapsed a group. Without any dep, switching grouping at runtime would have left the query asking for the old field — reaching this card's own (empty) bucket by a second route.

⚠️ One real defect, non-blocking: a comment misidentifies its own guard

ListView.tsx, on addGroupingField:

Safe to ask checkField here precisely BECAUSE addSpeculative ran first — everything reaching this point is a field the object declares

addSpeculative(f) is the last statement in that function, not the first, and addGroupingField is called with the raw collectGroupingFieldRefs(groupingConfig) output, which nothing has validated. The claim as written is false.

The code is correctcheckField is genuinely unreachable for an undeclared key, because knownObjectFields?.has(f) is an earlier conjunct in the same && chain and short-circuits. So the safety property holds; the comment just credits the wrong guard.

Not blocking, and not worth a CI cycle on its own. But worth fixing on the next touch of this file, because the failure mode is specific: a later refactor that trusts this comment could drop the knownObjectFields?.has(f) conjunct believing declaredness was already guaranteed upstream. This repo treats a comment that misdescribes its own mechanism as a real finding — #7204 is open right now for exactly that shape.

Discipline worth naming

NOT MEASURED was reported as neither red nor green.check:readme-exports and check:eager-closure both need a full monorepo build this worktree does not have, and each says so in its own words ("the population COLLAPSED, this run proves nothing"; "a broken gauge, not a sensitive gate"). Neither reads a file in this diff. Reporting those as green would have been the easy lie.

And the rate-limit trap was refused. The duplicate check for a new finding could not run, so the finding was reported as FILING BLOCKED rather than filed or dropped — with the sharp part recorded: /rate_limitanswered, reporting core 14152 and graphql 10000 remaining, and those numbers are not evidence the channel is open. Acting on them would have been the trap. That is the same "I read a result" ≠ "I measured" shape as #7089 §6.

The four out-of-scope findings are the PM's to file, including two FLS gaps that are security-adjacent. Filing them as quota allows; they are recorded here so they are not lost if this seat ends first.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 13:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a6d8b8dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7179-grid-grouping-projection branch September 1, 2026 14:17
os-warren pushed a commit that referenced this pull request Sep 1, 2026
Textual conflict only, in the two adjacent import statements at the top of
packages/plugin-list/src/ListView.tsx:
- main (#7214) extended the `@object-ui/core` import with `collectGroupingFieldRefs`
- this branch extended the `@object-ui/i18n` import with `pickLocalized`
Git folded the two adjacent lines into one hunk and could not take both sides.
Resolved by keeping BOTH: main's core import verbatim (so #7214's
`collectGroupingFieldRefs` still resolves at both projection call sites) and this
branch's i18n import (so `pickLocalized` still resolves at the description read
site). Verified mechanically that each side differed from the other by exactly
the one added symbol, so nothing else was hand-merged.
Nothing #7214 added was touched: the diff of this file against origin/main is
exactly this branch's own change — the i18n import line, the `viewDescription`
const, and the description render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

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

fix(grid,list,core): union a grid's grouping fields into the query projection - #7214

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection
Sep 1, 2026
Merged

fix(grid,list,core): union a grid's grouping fields into the query projection#7214
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7179

A grid view declaring grouping on a field absent from its columns rendered one group
labelled (empty) holding every row. $select was built from columns alone, so the
grouping field was never requested and was undefined on every row by the time grouping
ran. The grouping fields are now unioned into the projection — and into $expand — at
every site the projection is built.

What the dispatch assumed, and what the code actually says

The projection is built at TWO sites, not one. Both are on the grid's path and they
are independent, so a fix landing on one leaves the other blind:

sitewhen it runs
packages/plugin-grid/src/ObjectGrid.tsxgetSelectFields()the grid fetches for itself
packages/plugin-list/src/ListView.tsx — the selectFields IIFEListView fetches and hands the rows down to the grid

Both are fixed, and each has its own regression file so neither can silently regress
back to the other's behaviour.

"Three sibling adapters each union their groupByField" is not what the code does.
plugin-kanban, plugin-gantt and plugin-timeline contain no projection builder at
all
— a census of $select across their src/ returns zero hits. All three are served
by one shared arm, collectViewFields in ListView.tsx, which reads groupByField /
groupField / groupBy as plain strings. The grid was the only projection-blind path of
the four not because it lacked an adapter, but because it spells the same intent
differently: grouping.fields[], an array of objects, matching none of those candidate
keys. The correction stands — those three files are untouched here.

grouping.fields[] entries carry a plain string, confirmed against the spec.
GroupingFieldSchema in @objectstack/spec@17.2.0 is a $strict object of exactly
{ field: z.ZodString, order, collapsed }. No expression form, no nested-path form. A
bare-string shorthand is refused, deliberately: the schema does not accept it, and
useGroupedData reads f.field off each entry, so a bare string groups by undefined
no matter what the projection asks for. Reading it anyway would be the lenient
renderer-side alias AGENTS.md #0.1 forbids.

populate IS part of the fix. A select that fetches a bare foreign key without
expanding it buckets by raw id instead of by name — a different wrong answer, not a fix.
The expandFields memo in ListView.tsx already records this exact failure for kanban
in its own comment ("list view shows 'Initech Solutions' but kanban used to show
'8UY9zHWBfjYjYor4'"), and a grid grouped by a lookup landed in it. Grouping fields are
now unioned into $expand at both sites, pinned by its own test on each side.

The hazard, and why the union is gated

An unguarded union would have been strictly worse than the bug. A grouping.fields[]
entry has never been through column validation — that is the card's whole premise — and
some backends answer an unknown $select key with an empty result set rather than
ignoring it. Unioned raw, a grouping field naming a field the object does not declare
turns one (empty) group holding every row into zero rows, equally silently.

So grouping fields land on the speculative, gated side of both builders, never
alongside the known-valid columns:

  • Unknown-key gateisProjectableField in ObjectGrid, addSpeculative in
    ListView. A grouping field the object does not declare is dropped, and the rest of
    the projection survives intact (pinned in both directions: the unknown key is absent
    and the columns are still there).
  • Field-level securitypassesProjectionGate in ObjectGrid, and a new
    addGroupingField wrapper in ListView. A grouping field can name a denied field
    exactly as a column can, and the projection is the half that goes on the wire, so
    unioning after the FLS filter would have reopened objectui#6898 through a new door.
    projectionFls-6898.test.tsx is run here as a regression control.
  • $expand needs no unknown-key gate and deliberately does not get one:
    buildExpandFields returns a subset of the object's declared reference-bearing
    fields, so an unknown or non-relational grouping field is dropped structurally. Pinned
    anyway, so a later refactor cannot make that accidental.

ObjectGrid's fetch effect gains a groupingProjectionKey dep — a content key over the
grouping field names only. Grouping is runtime-mutable (the toolbar popover rewrites
it), so without it, switching the grouping field would leave the query asking for the old
one and the new grouping would read undefined on every row — the same (empty) bucket,
reachable a second way. Names only, because order and collapsed are render-time
concerns: collapsing a group must not cost a round trip.

Ablation

Direction and counts predicted before running; each leg's mutation proven on disk by
anchored marker count and blob hash; each restore proven by state (git diff HEAD,
git diff --cached, git status --short all empty) using git checkout HEAD -- with an
absolute path, never the bare form. The passing count is asserted alongside the red so a
collapsed population cannot pass for a clean run. Restore is trapped on EXIT INT TERM.

legmutationpredictedobserved
AObjectGrid projection union removed5 failed / 47 passed5 failed / 47 passed
BListView projection union removed4 failed / 48 passed4 failed / 48 passed
Cboth $expand augmentations removed2 failed / 50 passed2 failed / 50 passed

All three matched exactly. Leg A leaves the ListView file fully green and leg B leaves
the ObjectGrid file fully green — which is the measurement that shows the two sites are
genuinely independent, and that a one-site fix would have shipped looking correct.

Marker evidence per leg, e.g. leg A: removed-token 1 -> 0, injected-token 0 -> 1,
blob 0a3f9248 to 488b47ab. Restore confirmed both files byte-identical to their HEAD
blobs.

Verification

All runs below are on the final commit, 8006103ba.

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/core/
    272 files / 3846 tests passed, exit 0.
  • pnpm exec vitest run on the four suites that matter (the two new projection files,
    the new core unit file, and projectionFls-6898.test.tsx as regression control) —
    4 files / 52 tests passed, exit 0. File count asserted as the control that the
    right suites ran.
  • pnpm lint (the whole farm, eslint . --no-inline-config) — 47/47 tasks
    successful
    , exit 0. Not narrowed.
  • pnpm exec turbo run type-check for the three packages — 16/16 tasks successful,
    exit 0. Each package runs tsc --noEmit && tsc -p tsconfig.test.json, and all three
    new test files were confirmed present in the test program via --listFiles, so the
    green covers the tests and not just the sources.
  • Gates derived by hand from this repo's package.json and workflows (this repo has no
    scripts/pm/, and objectstack's dispatch-gates script answers only about its own
    tree): check:control-bytes, check:self-import, check:esm-specifiers,
    check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:side-effects-array, check:entry-guard, check-changeset-presence,
    check-changeset-no-majorall exit 0, each read from the gate's own printed
    verdict line rather than a bare exit status.
  • NOT MEASURED, reported as neither colour:check:readme-exports and
    check:eager-closure both require a full monorepo build this worktree does not have.
    Each says so itself — readme-exports reports "the population COLLAPSED, this run
    proves nothing" with 24 packages unbuilt, and eager-closure reports a missing
    apps/console/dist/eager-closure.json and calls itself "a broken gauge, not a
    sensitive gate". Neither reads any file in this diff (no README and no entry manifest
    is touched). CI builds before it runs them.

Exit codes throughout were captured by redirecting to a file first, never read after a
pipe.

Scope

The (empty) guard in useGroupedData.ts is untouched — it is correct for a
genuinely empty value and cannot distinguish it from a field that was never fetched. The
defect was upstream of it. sort is untouched, per the reporter recording it as
unmeasured rather than cleared. The kanban, gantt and timeline paths are untouched.

Out of scope, found while measuring

Not fixed here, and not yet filed — this seat's GitHub search returned
API rate limit already exceeded, so the mandatory duplicate check could not be run and
filing blind would risk duplicates. Handed to the PM in the structured report:

  1. A null entry in grouping.fields[] crashes the whole grid.ObjectGrid's
    groupValueFormatter memo dereferences gf.field with no null guard, throwing a
    TypeError before any projection is built. Confirmed pre-existing at this branch's
    base commit 4f596e01a. A different defect class from this card's silent wrong
    answer, so it is filed rather than ridden. The new harvester handles null correctly
    and that is pinned directly in the core unit test, which needs no grid to mount.
  2. $expand carries no FLS gate at either site.$select is FLS-gated on both
    paths, but buildExpandFields is handed a column list that was never filtered, so a
    denied lookup column is still expanded. Adjacent to objectui#6898 and affects columns
    generally, not grouping, so fixing it here would widen the review surface past this
    card.
  3. ListView's speculative view bindings are not FLS-gated. The kanban / gantt /
    timeline / calendar / gallery fields routed through addSpeculative are checked
    against the known-field set but never against checkField. The grouping field added
    by this PR is FLS-gated, so this PR does not extend the gap — it leaves an
    asymmetry worth closing deliberately rather than as a rider.

Generated by Claude Code


Generated by Claude Code

…ojection
A grid view declaring `grouping` on a field absent from its `columns` rendered
one group labelled `(empty)` holding every row. `$select` was built from the
view's `columns` alone, so the grouping field was never requested and was
`undefined` on every row by the time grouping ran.
The fix is in the projection, at both sites it is built (`ObjectGrid` when it
fetches for itself, `ListView` when it fetches for the grid), plus `$expand` so
a lookup grouping field groups by name rather than by raw foreign key.
The union is gated: grouping fields are intersected with the object's declared
fields and pass the same FLS gate as columns, because an unknown `$select` key
zeroes the whole list on backends that reject rather than ignore it.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3158.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D1fw90cR.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)207.50KB56.13KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.45KB27.64KB
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 at zero API quota (the seat is rate-limited): merge is clean against origin/main39d69ad53 (git merge-tree --write-tree exit 0), changeset present, 8 files.

The mid-flight correction was load-bearing, and the ablation is what proves it

My dispatch order asserted a single$select build site. That was wrong, and I sent the correction after grepping — but a correction is only a claim until it is measured. Legs A and B are the measurement:

leg A, ObjectGrid projection union removed → 5 failed / 47 passed. Leg A left the ListView file fully green.
leg B, ListView projection union removed → 4 failed / 48 passed. Leg B left the ObjectGrid file fully green.

That pair is the finding, not a formality: each site fails alone, so a one-site fix would have shipped with a green suite and a still-broken surface. All three legs matched their predicted counts exactly, with the PASSING count asserted alongside each red — so a collapsed population could not have passed for a clean run.

Both hazards I flagged are closed, and the reasoning is right

  • Unknown-key → zeroed list.isProjectableField intersects against the declared fields before anything reaches $select. The comment states the stake correctly: an unguarded union would have traded one (empty) group holding 186 rows for zero rows, equally silently — strictly worse, because the current bug at least shows the data.
  • FLS (objectui#6898).passesProjectionGate on the grid half, checkField on the list half, both applied before the union rather than after. The regression suite ran green as a control.
  • $expand was the right call to make unguarded, and for the stated structural reason — buildExpandFields returns a subset of the object's declared reference-bearing fields, so an unknown or non-relational entry is dropped by construction.

Three things I did not ask for and would have missed:

  1. $expand at all. My ZONE 2 asked whether populate was part of the fix and said to measure it. It is: without it a lookup grouping buckets by raw foreign key (8UY9zHWBfjYjYor4) instead of by name — better than one (empty) bucket, still wrong.
  2. The expandColumns ?? undefined conditional. Passing an array unconditionally would have narrowed the no-columns case from "every relation" to "the grouping fields alone". That is a regression the obvious patch introduces silently.
  3. groupingProjectionKey as a content key over the field NAMES only. Naming schema.grouping directly would have re-issued the query every render (hosts spread a fresh literal — the census(finding): three more renderer fetch effects key on a memoised object identity, outside objectui#6592's dataConfig family #6697 identity-churn storm), and keying the whole block would have cost a round trip every time a user collapsed a group. Without any dep, switching grouping at runtime would have left the query asking for the old field — reaching this card's own (empty) bucket by a second route.

⚠️ One real defect, non-blocking: a comment misidentifies its own guard

ListView.tsx, on addGroupingField:

Safe to ask checkField here precisely BECAUSE addSpeculative ran first — everything reaching this point is a field the object declares

addSpeculative(f) is the last statement in that function, not the first, and addGroupingField is called with the raw collectGroupingFieldRefs(groupingConfig) output, which nothing has validated. The claim as written is false.

The code is correctcheckField is genuinely unreachable for an undeclared key, because knownObjectFields?.has(f) is an earlier conjunct in the same && chain and short-circuits. So the safety property holds; the comment just credits the wrong guard.

Not blocking, and not worth a CI cycle on its own. But worth fixing on the next touch of this file, because the failure mode is specific: a later refactor that trusts this comment could drop the knownObjectFields?.has(f) conjunct believing declaredness was already guaranteed upstream. This repo treats a comment that misdescribes its own mechanism as a real finding — #7204 is open right now for exactly that shape.

Discipline worth naming

NOT MEASURED was reported as neither red nor green.check:readme-exports and check:eager-closure both need a full monorepo build this worktree does not have, and each says so in its own words ("the population COLLAPSED, this run proves nothing"; "a broken gauge, not a sensitive gate"). Neither reads a file in this diff. Reporting those as green would have been the easy lie.

And the rate-limit trap was refused. The duplicate check for a new finding could not run, so the finding was reported as FILING BLOCKED rather than filed or dropped — with the sharp part recorded: /rate_limitanswered, reporting core 14152 and graphql 10000 remaining, and those numbers are not evidence the channel is open. Acting on them would have been the trap. That is the same "I read a result" ≠ "I measured" shape as #7089 §6.

The four out-of-scope findings are the PM's to file, including two FLS gaps that are security-adjacent. Filing them as quota allows; they are recorded here so they are not lost if this seat ends first.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 13:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a6d8b8dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7179-grid-grouping-projection branch September 1, 2026 14:17
os-warren pushed a commit that referenced this pull request Sep 1, 2026
Textual conflict only, in the two adjacent import statements at the top of
packages/plugin-list/src/ListView.tsx:
- main (#7214) extended the `@object-ui/core` import with `collectGroupingFieldRefs`
- this branch extended the `@object-ui/i18n` import with `pickLocalized`
Git folded the two adjacent lines into one hunk and could not take both sides.
Resolved by keeping BOTH: main's core import verbatim (so #7214's
`collectGroupingFieldRefs` still resolves at both projection call sites) and this
branch's i18n import (so `pickLocalized` still resolves at the description read
site). Verified mechanically that each side differed from the other by exactly
the one added symbol, so nothing else was hand-merged.
Nothing #7214 added was touched: the diff of this file against origin/main is
exactly this branch's own change — the i18n import line, the `viewDescription`
const, and the description render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

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

fix(grid,list,core): union a grid's grouping fields into the query projection - #7214

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection
Sep 1, 2026
Merged

fix(grid,list,core): union a grid's grouping fields into the query projection#7214
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7179

A grid view declaring grouping on a field absent from its columns rendered one group
labelled (empty) holding every row. $select was built from columns alone, so the
grouping field was never requested and was undefined on every row by the time grouping
ran. The grouping fields are now unioned into the projection — and into $expand — at
every site the projection is built.

What the dispatch assumed, and what the code actually says

The projection is built at TWO sites, not one. Both are on the grid's path and they
are independent, so a fix landing on one leaves the other blind:

sitewhen it runs
packages/plugin-grid/src/ObjectGrid.tsxgetSelectFields()the grid fetches for itself
packages/plugin-list/src/ListView.tsx — the selectFields IIFEListView fetches and hands the rows down to the grid

Both are fixed, and each has its own regression file so neither can silently regress
back to the other's behaviour.

"Three sibling adapters each union their groupByField" is not what the code does.
plugin-kanban, plugin-gantt and plugin-timeline contain no projection builder at
all
— a census of $select across their src/ returns zero hits. All three are served
by one shared arm, collectViewFields in ListView.tsx, which reads groupByField /
groupField / groupBy as plain strings. The grid was the only projection-blind path of
the four not because it lacked an adapter, but because it spells the same intent
differently: grouping.fields[], an array of objects, matching none of those candidate
keys. The correction stands — those three files are untouched here.

grouping.fields[] entries carry a plain string, confirmed against the spec.
GroupingFieldSchema in @objectstack/spec@17.2.0 is a $strict object of exactly
{ field: z.ZodString, order, collapsed }. No expression form, no nested-path form. A
bare-string shorthand is refused, deliberately: the schema does not accept it, and
useGroupedData reads f.field off each entry, so a bare string groups by undefined
no matter what the projection asks for. Reading it anyway would be the lenient
renderer-side alias AGENTS.md #0.1 forbids.

populate IS part of the fix. A select that fetches a bare foreign key without
expanding it buckets by raw id instead of by name — a different wrong answer, not a fix.
The expandFields memo in ListView.tsx already records this exact failure for kanban
in its own comment ("list view shows 'Initech Solutions' but kanban used to show
'8UY9zHWBfjYjYor4'"), and a grid grouped by a lookup landed in it. Grouping fields are
now unioned into $expand at both sites, pinned by its own test on each side.

The hazard, and why the union is gated

An unguarded union would have been strictly worse than the bug. A grouping.fields[]
entry has never been through column validation — that is the card's whole premise — and
some backends answer an unknown $select key with an empty result set rather than
ignoring it. Unioned raw, a grouping field naming a field the object does not declare
turns one (empty) group holding every row into zero rows, equally silently.

So grouping fields land on the speculative, gated side of both builders, never
alongside the known-valid columns:

  • Unknown-key gateisProjectableField in ObjectGrid, addSpeculative in
    ListView. A grouping field the object does not declare is dropped, and the rest of
    the projection survives intact (pinned in both directions: the unknown key is absent
    and the columns are still there).
  • Field-level securitypassesProjectionGate in ObjectGrid, and a new
    addGroupingField wrapper in ListView. A grouping field can name a denied field
    exactly as a column can, and the projection is the half that goes on the wire, so
    unioning after the FLS filter would have reopened objectui#6898 through a new door.
    projectionFls-6898.test.tsx is run here as a regression control.
  • $expand needs no unknown-key gate and deliberately does not get one:
    buildExpandFields returns a subset of the object's declared reference-bearing
    fields, so an unknown or non-relational grouping field is dropped structurally. Pinned
    anyway, so a later refactor cannot make that accidental.

ObjectGrid's fetch effect gains a groupingProjectionKey dep — a content key over the
grouping field names only. Grouping is runtime-mutable (the toolbar popover rewrites
it), so without it, switching the grouping field would leave the query asking for the old
one and the new grouping would read undefined on every row — the same (empty) bucket,
reachable a second way. Names only, because order and collapsed are render-time
concerns: collapsing a group must not cost a round trip.

Ablation

Direction and counts predicted before running; each leg's mutation proven on disk by
anchored marker count and blob hash; each restore proven by state (git diff HEAD,
git diff --cached, git status --short all empty) using git checkout HEAD -- with an
absolute path, never the bare form. The passing count is asserted alongside the red so a
collapsed population cannot pass for a clean run. Restore is trapped on EXIT INT TERM.

legmutationpredictedobserved
AObjectGrid projection union removed5 failed / 47 passed5 failed / 47 passed
BListView projection union removed4 failed / 48 passed4 failed / 48 passed
Cboth $expand augmentations removed2 failed / 50 passed2 failed / 50 passed

All three matched exactly. Leg A leaves the ListView file fully green and leg B leaves
the ObjectGrid file fully green — which is the measurement that shows the two sites are
genuinely independent, and that a one-site fix would have shipped looking correct.

Marker evidence per leg, e.g. leg A: removed-token 1 -> 0, injected-token 0 -> 1,
blob 0a3f9248 to 488b47ab. Restore confirmed both files byte-identical to their HEAD
blobs.

Verification

All runs below are on the final commit, 8006103ba.

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/core/
    272 files / 3846 tests passed, exit 0.
  • pnpm exec vitest run on the four suites that matter (the two new projection files,
    the new core unit file, and projectionFls-6898.test.tsx as regression control) —
    4 files / 52 tests passed, exit 0. File count asserted as the control that the
    right suites ran.
  • pnpm lint (the whole farm, eslint . --no-inline-config) — 47/47 tasks
    successful
    , exit 0. Not narrowed.
  • pnpm exec turbo run type-check for the three packages — 16/16 tasks successful,
    exit 0. Each package runs tsc --noEmit && tsc -p tsconfig.test.json, and all three
    new test files were confirmed present in the test program via --listFiles, so the
    green covers the tests and not just the sources.
  • Gates derived by hand from this repo's package.json and workflows (this repo has no
    scripts/pm/, and objectstack's dispatch-gates script answers only about its own
    tree): check:control-bytes, check:self-import, check:esm-specifiers,
    check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:side-effects-array, check:entry-guard, check-changeset-presence,
    check-changeset-no-majorall exit 0, each read from the gate's own printed
    verdict line rather than a bare exit status.
  • NOT MEASURED, reported as neither colour:check:readme-exports and
    check:eager-closure both require a full monorepo build this worktree does not have.
    Each says so itself — readme-exports reports "the population COLLAPSED, this run
    proves nothing" with 24 packages unbuilt, and eager-closure reports a missing
    apps/console/dist/eager-closure.json and calls itself "a broken gauge, not a
    sensitive gate". Neither reads any file in this diff (no README and no entry manifest
    is touched). CI builds before it runs them.

Exit codes throughout were captured by redirecting to a file first, never read after a
pipe.

Scope

The (empty) guard in useGroupedData.ts is untouched — it is correct for a
genuinely empty value and cannot distinguish it from a field that was never fetched. The
defect was upstream of it. sort is untouched, per the reporter recording it as
unmeasured rather than cleared. The kanban, gantt and timeline paths are untouched.

Out of scope, found while measuring

Not fixed here, and not yet filed — this seat's GitHub search returned
API rate limit already exceeded, so the mandatory duplicate check could not be run and
filing blind would risk duplicates. Handed to the PM in the structured report:

  1. A null entry in grouping.fields[] crashes the whole grid.ObjectGrid's
    groupValueFormatter memo dereferences gf.field with no null guard, throwing a
    TypeError before any projection is built. Confirmed pre-existing at this branch's
    base commit 4f596e01a. A different defect class from this card's silent wrong
    answer, so it is filed rather than ridden. The new harvester handles null correctly
    and that is pinned directly in the core unit test, which needs no grid to mount.
  2. $expand carries no FLS gate at either site.$select is FLS-gated on both
    paths, but buildExpandFields is handed a column list that was never filtered, so a
    denied lookup column is still expanded. Adjacent to objectui#6898 and affects columns
    generally, not grouping, so fixing it here would widen the review surface past this
    card.
  3. ListView's speculative view bindings are not FLS-gated. The kanban / gantt /
    timeline / calendar / gallery fields routed through addSpeculative are checked
    against the known-field set but never against checkField. The grouping field added
    by this PR is FLS-gated, so this PR does not extend the gap — it leaves an
    asymmetry worth closing deliberately rather than as a rider.

Generated by Claude Code


Generated by Claude Code

…ojection
A grid view declaring `grouping` on a field absent from its `columns` rendered
one group labelled `(empty)` holding every row. `$select` was built from the
view's `columns` alone, so the grouping field was never requested and was
`undefined` on every row by the time grouping ran.
The fix is in the projection, at both sites it is built (`ObjectGrid` when it
fetches for itself, `ListView` when it fetches for the grid), plus `$expand` so
a lookup grouping field groups by name rather than by raw foreign key.
The union is gated: grouping fields are intersected with the object's declared
fields and pass the same FLS gate as columns, because an unknown `$select` key
zeroes the whole list on backends that reject rather than ignore it.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3158.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D1fw90cR.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)207.50KB56.13KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.45KB27.64KB
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 at zero API quota (the seat is rate-limited): merge is clean against origin/main39d69ad53 (git merge-tree --write-tree exit 0), changeset present, 8 files.

The mid-flight correction was load-bearing, and the ablation is what proves it

My dispatch order asserted a single$select build site. That was wrong, and I sent the correction after grepping — but a correction is only a claim until it is measured. Legs A and B are the measurement:

leg A, ObjectGrid projection union removed → 5 failed / 47 passed. Leg A left the ListView file fully green.
leg B, ListView projection union removed → 4 failed / 48 passed. Leg B left the ObjectGrid file fully green.

That pair is the finding, not a formality: each site fails alone, so a one-site fix would have shipped with a green suite and a still-broken surface. All three legs matched their predicted counts exactly, with the PASSING count asserted alongside each red — so a collapsed population could not have passed for a clean run.

Both hazards I flagged are closed, and the reasoning is right

  • Unknown-key → zeroed list.isProjectableField intersects against the declared fields before anything reaches $select. The comment states the stake correctly: an unguarded union would have traded one (empty) group holding 186 rows for zero rows, equally silently — strictly worse, because the current bug at least shows the data.
  • FLS (objectui#6898).passesProjectionGate on the grid half, checkField on the list half, both applied before the union rather than after. The regression suite ran green as a control.
  • $expand was the right call to make unguarded, and for the stated structural reason — buildExpandFields returns a subset of the object's declared reference-bearing fields, so an unknown or non-relational entry is dropped by construction.

Three things I did not ask for and would have missed:

  1. $expand at all. My ZONE 2 asked whether populate was part of the fix and said to measure it. It is: without it a lookup grouping buckets by raw foreign key (8UY9zHWBfjYjYor4) instead of by name — better than one (empty) bucket, still wrong.
  2. The expandColumns ?? undefined conditional. Passing an array unconditionally would have narrowed the no-columns case from "every relation" to "the grouping fields alone". That is a regression the obvious patch introduces silently.
  3. groupingProjectionKey as a content key over the field NAMES only. Naming schema.grouping directly would have re-issued the query every render (hosts spread a fresh literal — the census(finding): three more renderer fetch effects key on a memoised object identity, outside objectui#6592's dataConfig family #6697 identity-churn storm), and keying the whole block would have cost a round trip every time a user collapsed a group. Without any dep, switching grouping at runtime would have left the query asking for the old field — reaching this card's own (empty) bucket by a second route.

⚠️ One real defect, non-blocking: a comment misidentifies its own guard

ListView.tsx, on addGroupingField:

Safe to ask checkField here precisely BECAUSE addSpeculative ran first — everything reaching this point is a field the object declares

addSpeculative(f) is the last statement in that function, not the first, and addGroupingField is called with the raw collectGroupingFieldRefs(groupingConfig) output, which nothing has validated. The claim as written is false.

The code is correctcheckField is genuinely unreachable for an undeclared key, because knownObjectFields?.has(f) is an earlier conjunct in the same && chain and short-circuits. So the safety property holds; the comment just credits the wrong guard.

Not blocking, and not worth a CI cycle on its own. But worth fixing on the next touch of this file, because the failure mode is specific: a later refactor that trusts this comment could drop the knownObjectFields?.has(f) conjunct believing declaredness was already guaranteed upstream. This repo treats a comment that misdescribes its own mechanism as a real finding — #7204 is open right now for exactly that shape.

Discipline worth naming

NOT MEASURED was reported as neither red nor green.check:readme-exports and check:eager-closure both need a full monorepo build this worktree does not have, and each says so in its own words ("the population COLLAPSED, this run proves nothing"; "a broken gauge, not a sensitive gate"). Neither reads a file in this diff. Reporting those as green would have been the easy lie.

And the rate-limit trap was refused. The duplicate check for a new finding could not run, so the finding was reported as FILING BLOCKED rather than filed or dropped — with the sharp part recorded: /rate_limitanswered, reporting core 14152 and graphql 10000 remaining, and those numbers are not evidence the channel is open. Acting on them would have been the trap. That is the same "I read a result" ≠ "I measured" shape as #7089 §6.

The four out-of-scope findings are the PM's to file, including two FLS gaps that are security-adjacent. Filing them as quota allows; they are recorded here so they are not lost if this seat ends first.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 13:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a6d8b8dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7179-grid-grouping-projection branch September 1, 2026 14:17
os-warren pushed a commit that referenced this pull request Sep 1, 2026
Textual conflict only, in the two adjacent import statements at the top of
packages/plugin-list/src/ListView.tsx:
- main (#7214) extended the `@object-ui/core` import with `collectGroupingFieldRefs`
- this branch extended the `@object-ui/i18n` import with `pickLocalized`
Git folded the two adjacent lines into one hunk and could not take both sides.
Resolved by keeping BOTH: main's core import verbatim (so #7214's
`collectGroupingFieldRefs` still resolves at both projection call sites) and this
branch's i18n import (so `pickLocalized` still resolves at the description read
site). Verified mechanically that each side differed from the other by exactly
the one added symbol, so nothing else was hand-merged.
Nothing #7214 added was touched: the diff of this file against origin/main is
exactly this branch's own change — the i18n import line, the `viewDescription`
const, and the description render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

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

fix(grid,list,core): union a grid's grouping fields into the query projection - #7214

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection
Sep 1, 2026
Merged

fix(grid,list,core): union a grid's grouping fields into the query projection#7214
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7179

A grid view declaring grouping on a field absent from its columns rendered one group
labelled (empty) holding every row. $select was built from columns alone, so the
grouping field was never requested and was undefined on every row by the time grouping
ran. The grouping fields are now unioned into the projection — and into $expand — at
every site the projection is built.

What the dispatch assumed, and what the code actually says

The projection is built at TWO sites, not one. Both are on the grid's path and they
are independent, so a fix landing on one leaves the other blind:

sitewhen it runs
packages/plugin-grid/src/ObjectGrid.tsxgetSelectFields()the grid fetches for itself
packages/plugin-list/src/ListView.tsx — the selectFields IIFEListView fetches and hands the rows down to the grid

Both are fixed, and each has its own regression file so neither can silently regress
back to the other's behaviour.

"Three sibling adapters each union their groupByField" is not what the code does.
plugin-kanban, plugin-gantt and plugin-timeline contain no projection builder at
all
— a census of $select across their src/ returns zero hits. All three are served
by one shared arm, collectViewFields in ListView.tsx, which reads groupByField /
groupField / groupBy as plain strings. The grid was the only projection-blind path of
the four not because it lacked an adapter, but because it spells the same intent
differently: grouping.fields[], an array of objects, matching none of those candidate
keys. The correction stands — those three files are untouched here.

grouping.fields[] entries carry a plain string, confirmed against the spec.
GroupingFieldSchema in @objectstack/spec@17.2.0 is a $strict object of exactly
{ field: z.ZodString, order, collapsed }. No expression form, no nested-path form. A
bare-string shorthand is refused, deliberately: the schema does not accept it, and
useGroupedData reads f.field off each entry, so a bare string groups by undefined
no matter what the projection asks for. Reading it anyway would be the lenient
renderer-side alias AGENTS.md #0.1 forbids.

populate IS part of the fix. A select that fetches a bare foreign key without
expanding it buckets by raw id instead of by name — a different wrong answer, not a fix.
The expandFields memo in ListView.tsx already records this exact failure for kanban
in its own comment ("list view shows 'Initech Solutions' but kanban used to show
'8UY9zHWBfjYjYor4'"), and a grid grouped by a lookup landed in it. Grouping fields are
now unioned into $expand at both sites, pinned by its own test on each side.

The hazard, and why the union is gated

An unguarded union would have been strictly worse than the bug. A grouping.fields[]
entry has never been through column validation — that is the card's whole premise — and
some backends answer an unknown $select key with an empty result set rather than
ignoring it. Unioned raw, a grouping field naming a field the object does not declare
turns one (empty) group holding every row into zero rows, equally silently.

So grouping fields land on the speculative, gated side of both builders, never
alongside the known-valid columns:

  • Unknown-key gateisProjectableField in ObjectGrid, addSpeculative in
    ListView. A grouping field the object does not declare is dropped, and the rest of
    the projection survives intact (pinned in both directions: the unknown key is absent
    and the columns are still there).
  • Field-level securitypassesProjectionGate in ObjectGrid, and a new
    addGroupingField wrapper in ListView. A grouping field can name a denied field
    exactly as a column can, and the projection is the half that goes on the wire, so
    unioning after the FLS filter would have reopened objectui#6898 through a new door.
    projectionFls-6898.test.tsx is run here as a regression control.
  • $expand needs no unknown-key gate and deliberately does not get one:
    buildExpandFields returns a subset of the object's declared reference-bearing
    fields, so an unknown or non-relational grouping field is dropped structurally. Pinned
    anyway, so a later refactor cannot make that accidental.

ObjectGrid's fetch effect gains a groupingProjectionKey dep — a content key over the
grouping field names only. Grouping is runtime-mutable (the toolbar popover rewrites
it), so without it, switching the grouping field would leave the query asking for the old
one and the new grouping would read undefined on every row — the same (empty) bucket,
reachable a second way. Names only, because order and collapsed are render-time
concerns: collapsing a group must not cost a round trip.

Ablation

Direction and counts predicted before running; each leg's mutation proven on disk by
anchored marker count and blob hash; each restore proven by state (git diff HEAD,
git diff --cached, git status --short all empty) using git checkout HEAD -- with an
absolute path, never the bare form. The passing count is asserted alongside the red so a
collapsed population cannot pass for a clean run. Restore is trapped on EXIT INT TERM.

legmutationpredictedobserved
AObjectGrid projection union removed5 failed / 47 passed5 failed / 47 passed
BListView projection union removed4 failed / 48 passed4 failed / 48 passed
Cboth $expand augmentations removed2 failed / 50 passed2 failed / 50 passed

All three matched exactly. Leg A leaves the ListView file fully green and leg B leaves
the ObjectGrid file fully green — which is the measurement that shows the two sites are
genuinely independent, and that a one-site fix would have shipped looking correct.

Marker evidence per leg, e.g. leg A: removed-token 1 -> 0, injected-token 0 -> 1,
blob 0a3f9248 to 488b47ab. Restore confirmed both files byte-identical to their HEAD
blobs.

Verification

All runs below are on the final commit, 8006103ba.

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/core/
    272 files / 3846 tests passed, exit 0.
  • pnpm exec vitest run on the four suites that matter (the two new projection files,
    the new core unit file, and projectionFls-6898.test.tsx as regression control) —
    4 files / 52 tests passed, exit 0. File count asserted as the control that the
    right suites ran.
  • pnpm lint (the whole farm, eslint . --no-inline-config) — 47/47 tasks
    successful
    , exit 0. Not narrowed.
  • pnpm exec turbo run type-check for the three packages — 16/16 tasks successful,
    exit 0. Each package runs tsc --noEmit && tsc -p tsconfig.test.json, and all three
    new test files were confirmed present in the test program via --listFiles, so the
    green covers the tests and not just the sources.
  • Gates derived by hand from this repo's package.json and workflows (this repo has no
    scripts/pm/, and objectstack's dispatch-gates script answers only about its own
    tree): check:control-bytes, check:self-import, check:esm-specifiers,
    check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:side-effects-array, check:entry-guard, check-changeset-presence,
    check-changeset-no-majorall exit 0, each read from the gate's own printed
    verdict line rather than a bare exit status.
  • NOT MEASURED, reported as neither colour:check:readme-exports and
    check:eager-closure both require a full monorepo build this worktree does not have.
    Each says so itself — readme-exports reports "the population COLLAPSED, this run
    proves nothing" with 24 packages unbuilt, and eager-closure reports a missing
    apps/console/dist/eager-closure.json and calls itself "a broken gauge, not a
    sensitive gate". Neither reads any file in this diff (no README and no entry manifest
    is touched). CI builds before it runs them.

Exit codes throughout were captured by redirecting to a file first, never read after a
pipe.

Scope

The (empty) guard in useGroupedData.ts is untouched — it is correct for a
genuinely empty value and cannot distinguish it from a field that was never fetched. The
defect was upstream of it. sort is untouched, per the reporter recording it as
unmeasured rather than cleared. The kanban, gantt and timeline paths are untouched.

Out of scope, found while measuring

Not fixed here, and not yet filed — this seat's GitHub search returned
API rate limit already exceeded, so the mandatory duplicate check could not be run and
filing blind would risk duplicates. Handed to the PM in the structured report:

  1. A null entry in grouping.fields[] crashes the whole grid.ObjectGrid's
    groupValueFormatter memo dereferences gf.field with no null guard, throwing a
    TypeError before any projection is built. Confirmed pre-existing at this branch's
    base commit 4f596e01a. A different defect class from this card's silent wrong
    answer, so it is filed rather than ridden. The new harvester handles null correctly
    and that is pinned directly in the core unit test, which needs no grid to mount.
  2. $expand carries no FLS gate at either site.$select is FLS-gated on both
    paths, but buildExpandFields is handed a column list that was never filtered, so a
    denied lookup column is still expanded. Adjacent to objectui#6898 and affects columns
    generally, not grouping, so fixing it here would widen the review surface past this
    card.
  3. ListView's speculative view bindings are not FLS-gated. The kanban / gantt /
    timeline / calendar / gallery fields routed through addSpeculative are checked
    against the known-field set but never against checkField. The grouping field added
    by this PR is FLS-gated, so this PR does not extend the gap — it leaves an
    asymmetry worth closing deliberately rather than as a rider.

Generated by Claude Code


Generated by Claude Code

…ojection
A grid view declaring `grouping` on a field absent from its `columns` rendered
one group labelled `(empty)` holding every row. `$select` was built from the
view's `columns` alone, so the grouping field was never requested and was
`undefined` on every row by the time grouping ran.
The fix is in the projection, at both sites it is built (`ObjectGrid` when it
fetches for itself, `ListView` when it fetches for the grid), plus `$expand` so
a lookup grouping field groups by name rather than by raw foreign key.
The union is gated: grouping fields are intersected with the object's declared
fields and pass the same FLS gate as columns, because an unknown `$select` key
zeroes the whole list on backends that reject rather than ignore it.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3158.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D1fw90cR.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)207.50KB56.13KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.45KB27.64KB
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 at zero API quota (the seat is rate-limited): merge is clean against origin/main39d69ad53 (git merge-tree --write-tree exit 0), changeset present, 8 files.

The mid-flight correction was load-bearing, and the ablation is what proves it

My dispatch order asserted a single$select build site. That was wrong, and I sent the correction after grepping — but a correction is only a claim until it is measured. Legs A and B are the measurement:

leg A, ObjectGrid projection union removed → 5 failed / 47 passed. Leg A left the ListView file fully green.
leg B, ListView projection union removed → 4 failed / 48 passed. Leg B left the ObjectGrid file fully green.

That pair is the finding, not a formality: each site fails alone, so a one-site fix would have shipped with a green suite and a still-broken surface. All three legs matched their predicted counts exactly, with the PASSING count asserted alongside each red — so a collapsed population could not have passed for a clean run.

Both hazards I flagged are closed, and the reasoning is right

  • Unknown-key → zeroed list.isProjectableField intersects against the declared fields before anything reaches $select. The comment states the stake correctly: an unguarded union would have traded one (empty) group holding 186 rows for zero rows, equally silently — strictly worse, because the current bug at least shows the data.
  • FLS (objectui#6898).passesProjectionGate on the grid half, checkField on the list half, both applied before the union rather than after. The regression suite ran green as a control.
  • $expand was the right call to make unguarded, and for the stated structural reason — buildExpandFields returns a subset of the object's declared reference-bearing fields, so an unknown or non-relational entry is dropped by construction.

Three things I did not ask for and would have missed:

  1. $expand at all. My ZONE 2 asked whether populate was part of the fix and said to measure it. It is: without it a lookup grouping buckets by raw foreign key (8UY9zHWBfjYjYor4) instead of by name — better than one (empty) bucket, still wrong.
  2. The expandColumns ?? undefined conditional. Passing an array unconditionally would have narrowed the no-columns case from "every relation" to "the grouping fields alone". That is a regression the obvious patch introduces silently.
  3. groupingProjectionKey as a content key over the field NAMES only. Naming schema.grouping directly would have re-issued the query every render (hosts spread a fresh literal — the census(finding): three more renderer fetch effects key on a memoised object identity, outside objectui#6592's dataConfig family #6697 identity-churn storm), and keying the whole block would have cost a round trip every time a user collapsed a group. Without any dep, switching grouping at runtime would have left the query asking for the old field — reaching this card's own (empty) bucket by a second route.

⚠️ One real defect, non-blocking: a comment misidentifies its own guard

ListView.tsx, on addGroupingField:

Safe to ask checkField here precisely BECAUSE addSpeculative ran first — everything reaching this point is a field the object declares

addSpeculative(f) is the last statement in that function, not the first, and addGroupingField is called with the raw collectGroupingFieldRefs(groupingConfig) output, which nothing has validated. The claim as written is false.

The code is correctcheckField is genuinely unreachable for an undeclared key, because knownObjectFields?.has(f) is an earlier conjunct in the same && chain and short-circuits. So the safety property holds; the comment just credits the wrong guard.

Not blocking, and not worth a CI cycle on its own. But worth fixing on the next touch of this file, because the failure mode is specific: a later refactor that trusts this comment could drop the knownObjectFields?.has(f) conjunct believing declaredness was already guaranteed upstream. This repo treats a comment that misdescribes its own mechanism as a real finding — #7204 is open right now for exactly that shape.

Discipline worth naming

NOT MEASURED was reported as neither red nor green.check:readme-exports and check:eager-closure both need a full monorepo build this worktree does not have, and each says so in its own words ("the population COLLAPSED, this run proves nothing"; "a broken gauge, not a sensitive gate"). Neither reads a file in this diff. Reporting those as green would have been the easy lie.

And the rate-limit trap was refused. The duplicate check for a new finding could not run, so the finding was reported as FILING BLOCKED rather than filed or dropped — with the sharp part recorded: /rate_limitanswered, reporting core 14152 and graphql 10000 remaining, and those numbers are not evidence the channel is open. Acting on them would have been the trap. That is the same "I read a result" ≠ "I measured" shape as #7089 §6.

The four out-of-scope findings are the PM's to file, including two FLS gaps that are security-adjacent. Filing them as quota allows; they are recorded here so they are not lost if this seat ends first.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 13:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a6d8b8dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7179-grid-grouping-projection branch September 1, 2026 14:17
os-warren pushed a commit that referenced this pull request Sep 1, 2026
Textual conflict only, in the two adjacent import statements at the top of
packages/plugin-list/src/ListView.tsx:
- main (#7214) extended the `@object-ui/core` import with `collectGroupingFieldRefs`
- this branch extended the `@object-ui/i18n` import with `pickLocalized`
Git folded the two adjacent lines into one hunk and could not take both sides.
Resolved by keeping BOTH: main's core import verbatim (so #7214's
`collectGroupingFieldRefs` still resolves at both projection call sites) and this
branch's i18n import (so `pickLocalized` still resolves at the description read
site). Verified mechanically that each side differed from the other by exactly
the one added symbol, so nothing else was hand-merged.
Nothing #7214 added was touched: the diff of this file against origin/main is
exactly this branch's own change — the i18n import line, the `viewDescription`
const, and the description render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

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

fix(grid,list,core): union a grid's grouping fields into the query projection - #7214

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection
Sep 1, 2026
Merged

fix(grid,list,core): union a grid's grouping fields into the query projection#7214
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7179

A grid view declaring grouping on a field absent from its columns rendered one group
labelled (empty) holding every row. $select was built from columns alone, so the
grouping field was never requested and was undefined on every row by the time grouping
ran. The grouping fields are now unioned into the projection — and into $expand — at
every site the projection is built.

What the dispatch assumed, and what the code actually says

The projection is built at TWO sites, not one. Both are on the grid's path and they
are independent, so a fix landing on one leaves the other blind:

sitewhen it runs
packages/plugin-grid/src/ObjectGrid.tsxgetSelectFields()the grid fetches for itself
packages/plugin-list/src/ListView.tsx — the selectFields IIFEListView fetches and hands the rows down to the grid

Both are fixed, and each has its own regression file so neither can silently regress
back to the other's behaviour.

"Three sibling adapters each union their groupByField" is not what the code does.
plugin-kanban, plugin-gantt and plugin-timeline contain no projection builder at
all
— a census of $select across their src/ returns zero hits. All three are served
by one shared arm, collectViewFields in ListView.tsx, which reads groupByField /
groupField / groupBy as plain strings. The grid was the only projection-blind path of
the four not because it lacked an adapter, but because it spells the same intent
differently: grouping.fields[], an array of objects, matching none of those candidate
keys. The correction stands — those three files are untouched here.

grouping.fields[] entries carry a plain string, confirmed against the spec.
GroupingFieldSchema in @objectstack/spec@17.2.0 is a $strict object of exactly
{ field: z.ZodString, order, collapsed }. No expression form, no nested-path form. A
bare-string shorthand is refused, deliberately: the schema does not accept it, and
useGroupedData reads f.field off each entry, so a bare string groups by undefined
no matter what the projection asks for. Reading it anyway would be the lenient
renderer-side alias AGENTS.md #0.1 forbids.

populate IS part of the fix. A select that fetches a bare foreign key without
expanding it buckets by raw id instead of by name — a different wrong answer, not a fix.
The expandFields memo in ListView.tsx already records this exact failure for kanban
in its own comment ("list view shows 'Initech Solutions' but kanban used to show
'8UY9zHWBfjYjYor4'"), and a grid grouped by a lookup landed in it. Grouping fields are
now unioned into $expand at both sites, pinned by its own test on each side.

The hazard, and why the union is gated

An unguarded union would have been strictly worse than the bug. A grouping.fields[]
entry has never been through column validation — that is the card's whole premise — and
some backends answer an unknown $select key with an empty result set rather than
ignoring it. Unioned raw, a grouping field naming a field the object does not declare
turns one (empty) group holding every row into zero rows, equally silently.

So grouping fields land on the speculative, gated side of both builders, never
alongside the known-valid columns:

  • Unknown-key gateisProjectableField in ObjectGrid, addSpeculative in
    ListView. A grouping field the object does not declare is dropped, and the rest of
    the projection survives intact (pinned in both directions: the unknown key is absent
    and the columns are still there).
  • Field-level securitypassesProjectionGate in ObjectGrid, and a new
    addGroupingField wrapper in ListView. A grouping field can name a denied field
    exactly as a column can, and the projection is the half that goes on the wire, so
    unioning after the FLS filter would have reopened objectui#6898 through a new door.
    projectionFls-6898.test.tsx is run here as a regression control.
  • $expand needs no unknown-key gate and deliberately does not get one:
    buildExpandFields returns a subset of the object's declared reference-bearing
    fields, so an unknown or non-relational grouping field is dropped structurally. Pinned
    anyway, so a later refactor cannot make that accidental.

ObjectGrid's fetch effect gains a groupingProjectionKey dep — a content key over the
grouping field names only. Grouping is runtime-mutable (the toolbar popover rewrites
it), so without it, switching the grouping field would leave the query asking for the old
one and the new grouping would read undefined on every row — the same (empty) bucket,
reachable a second way. Names only, because order and collapsed are render-time
concerns: collapsing a group must not cost a round trip.

Ablation

Direction and counts predicted before running; each leg's mutation proven on disk by
anchored marker count and blob hash; each restore proven by state (git diff HEAD,
git diff --cached, git status --short all empty) using git checkout HEAD -- with an
absolute path, never the bare form. The passing count is asserted alongside the red so a
collapsed population cannot pass for a clean run. Restore is trapped on EXIT INT TERM.

legmutationpredictedobserved
AObjectGrid projection union removed5 failed / 47 passed5 failed / 47 passed
BListView projection union removed4 failed / 48 passed4 failed / 48 passed
Cboth $expand augmentations removed2 failed / 50 passed2 failed / 50 passed

All three matched exactly. Leg A leaves the ListView file fully green and leg B leaves
the ObjectGrid file fully green — which is the measurement that shows the two sites are
genuinely independent, and that a one-site fix would have shipped looking correct.

Marker evidence per leg, e.g. leg A: removed-token 1 -> 0, injected-token 0 -> 1,
blob 0a3f9248 to 488b47ab. Restore confirmed both files byte-identical to their HEAD
blobs.

Verification

All runs below are on the final commit, 8006103ba.

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/core/
    272 files / 3846 tests passed, exit 0.
  • pnpm exec vitest run on the four suites that matter (the two new projection files,
    the new core unit file, and projectionFls-6898.test.tsx as regression control) —
    4 files / 52 tests passed, exit 0. File count asserted as the control that the
    right suites ran.
  • pnpm lint (the whole farm, eslint . --no-inline-config) — 47/47 tasks
    successful
    , exit 0. Not narrowed.
  • pnpm exec turbo run type-check for the three packages — 16/16 tasks successful,
    exit 0. Each package runs tsc --noEmit && tsc -p tsconfig.test.json, and all three
    new test files were confirmed present in the test program via --listFiles, so the
    green covers the tests and not just the sources.
  • Gates derived by hand from this repo's package.json and workflows (this repo has no
    scripts/pm/, and objectstack's dispatch-gates script answers only about its own
    tree): check:control-bytes, check:self-import, check:esm-specifiers,
    check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:side-effects-array, check:entry-guard, check-changeset-presence,
    check-changeset-no-majorall exit 0, each read from the gate's own printed
    verdict line rather than a bare exit status.
  • NOT MEASURED, reported as neither colour:check:readme-exports and
    check:eager-closure both require a full monorepo build this worktree does not have.
    Each says so itself — readme-exports reports "the population COLLAPSED, this run
    proves nothing" with 24 packages unbuilt, and eager-closure reports a missing
    apps/console/dist/eager-closure.json and calls itself "a broken gauge, not a
    sensitive gate". Neither reads any file in this diff (no README and no entry manifest
    is touched). CI builds before it runs them.

Exit codes throughout were captured by redirecting to a file first, never read after a
pipe.

Scope

The (empty) guard in useGroupedData.ts is untouched — it is correct for a
genuinely empty value and cannot distinguish it from a field that was never fetched. The
defect was upstream of it. sort is untouched, per the reporter recording it as
unmeasured rather than cleared. The kanban, gantt and timeline paths are untouched.

Out of scope, found while measuring

Not fixed here, and not yet filed — this seat's GitHub search returned
API rate limit already exceeded, so the mandatory duplicate check could not be run and
filing blind would risk duplicates. Handed to the PM in the structured report:

  1. A null entry in grouping.fields[] crashes the whole grid.ObjectGrid's
    groupValueFormatter memo dereferences gf.field with no null guard, throwing a
    TypeError before any projection is built. Confirmed pre-existing at this branch's
    base commit 4f596e01a. A different defect class from this card's silent wrong
    answer, so it is filed rather than ridden. The new harvester handles null correctly
    and that is pinned directly in the core unit test, which needs no grid to mount.
  2. $expand carries no FLS gate at either site.$select is FLS-gated on both
    paths, but buildExpandFields is handed a column list that was never filtered, so a
    denied lookup column is still expanded. Adjacent to objectui#6898 and affects columns
    generally, not grouping, so fixing it here would widen the review surface past this
    card.
  3. ListView's speculative view bindings are not FLS-gated. The kanban / gantt /
    timeline / calendar / gallery fields routed through addSpeculative are checked
    against the known-field set but never against checkField. The grouping field added
    by this PR is FLS-gated, so this PR does not extend the gap — it leaves an
    asymmetry worth closing deliberately rather than as a rider.

Generated by Claude Code


Generated by Claude Code

…ojection
A grid view declaring `grouping` on a field absent from its `columns` rendered
one group labelled `(empty)` holding every row. `$select` was built from the
view's `columns` alone, so the grouping field was never requested and was
`undefined` on every row by the time grouping ran.
The fix is in the projection, at both sites it is built (`ObjectGrid` when it
fetches for itself, `ListView` when it fetches for the grid), plus `$expand` so
a lookup grouping field groups by name rather than by raw foreign key.
The union is gated: grouping fields are intersected with the object's declared
fields and pass the same FLS gate as columns, because an unknown `$select` key
zeroes the whole list on backends that reject rather than ignore it.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3158.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D1fw90cR.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)207.50KB56.13KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.45KB27.64KB
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 at zero API quota (the seat is rate-limited): merge is clean against origin/main39d69ad53 (git merge-tree --write-tree exit 0), changeset present, 8 files.

The mid-flight correction was load-bearing, and the ablation is what proves it

My dispatch order asserted a single$select build site. That was wrong, and I sent the correction after grepping — but a correction is only a claim until it is measured. Legs A and B are the measurement:

leg A, ObjectGrid projection union removed → 5 failed / 47 passed. Leg A left the ListView file fully green.
leg B, ListView projection union removed → 4 failed / 48 passed. Leg B left the ObjectGrid file fully green.

That pair is the finding, not a formality: each site fails alone, so a one-site fix would have shipped with a green suite and a still-broken surface. All three legs matched their predicted counts exactly, with the PASSING count asserted alongside each red — so a collapsed population could not have passed for a clean run.

Both hazards I flagged are closed, and the reasoning is right

  • Unknown-key → zeroed list.isProjectableField intersects against the declared fields before anything reaches $select. The comment states the stake correctly: an unguarded union would have traded one (empty) group holding 186 rows for zero rows, equally silently — strictly worse, because the current bug at least shows the data.
  • FLS (objectui#6898).passesProjectionGate on the grid half, checkField on the list half, both applied before the union rather than after. The regression suite ran green as a control.
  • $expand was the right call to make unguarded, and for the stated structural reason — buildExpandFields returns a subset of the object's declared reference-bearing fields, so an unknown or non-relational entry is dropped by construction.

Three things I did not ask for and would have missed:

  1. $expand at all. My ZONE 2 asked whether populate was part of the fix and said to measure it. It is: without it a lookup grouping buckets by raw foreign key (8UY9zHWBfjYjYor4) instead of by name — better than one (empty) bucket, still wrong.
  2. The expandColumns ?? undefined conditional. Passing an array unconditionally would have narrowed the no-columns case from "every relation" to "the grouping fields alone". That is a regression the obvious patch introduces silently.
  3. groupingProjectionKey as a content key over the field NAMES only. Naming schema.grouping directly would have re-issued the query every render (hosts spread a fresh literal — the census(finding): three more renderer fetch effects key on a memoised object identity, outside objectui#6592's dataConfig family #6697 identity-churn storm), and keying the whole block would have cost a round trip every time a user collapsed a group. Without any dep, switching grouping at runtime would have left the query asking for the old field — reaching this card's own (empty) bucket by a second route.

⚠️ One real defect, non-blocking: a comment misidentifies its own guard

ListView.tsx, on addGroupingField:

Safe to ask checkField here precisely BECAUSE addSpeculative ran first — everything reaching this point is a field the object declares

addSpeculative(f) is the last statement in that function, not the first, and addGroupingField is called with the raw collectGroupingFieldRefs(groupingConfig) output, which nothing has validated. The claim as written is false.

The code is correctcheckField is genuinely unreachable for an undeclared key, because knownObjectFields?.has(f) is an earlier conjunct in the same && chain and short-circuits. So the safety property holds; the comment just credits the wrong guard.

Not blocking, and not worth a CI cycle on its own. But worth fixing on the next touch of this file, because the failure mode is specific: a later refactor that trusts this comment could drop the knownObjectFields?.has(f) conjunct believing declaredness was already guaranteed upstream. This repo treats a comment that misdescribes its own mechanism as a real finding — #7204 is open right now for exactly that shape.

Discipline worth naming

NOT MEASURED was reported as neither red nor green.check:readme-exports and check:eager-closure both need a full monorepo build this worktree does not have, and each says so in its own words ("the population COLLAPSED, this run proves nothing"; "a broken gauge, not a sensitive gate"). Neither reads a file in this diff. Reporting those as green would have been the easy lie.

And the rate-limit trap was refused. The duplicate check for a new finding could not run, so the finding was reported as FILING BLOCKED rather than filed or dropped — with the sharp part recorded: /rate_limitanswered, reporting core 14152 and graphql 10000 remaining, and those numbers are not evidence the channel is open. Acting on them would have been the trap. That is the same "I read a result" ≠ "I measured" shape as #7089 §6.

The four out-of-scope findings are the PM's to file, including two FLS gaps that are security-adjacent. Filing them as quota allows; they are recorded here so they are not lost if this seat ends first.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 13:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a6d8b8dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7179-grid-grouping-projection branch September 1, 2026 14:17
os-warren pushed a commit that referenced this pull request Sep 1, 2026
Textual conflict only, in the two adjacent import statements at the top of
packages/plugin-list/src/ListView.tsx:
- main (#7214) extended the `@object-ui/core` import with `collectGroupingFieldRefs`
- this branch extended the `@object-ui/i18n` import with `pickLocalized`
Git folded the two adjacent lines into one hunk and could not take both sides.
Resolved by keeping BOTH: main's core import verbatim (so #7214's
`collectGroupingFieldRefs` still resolves at both projection call sites) and this
branch's i18n import (so `pickLocalized` still resolves at the description read
site). Verified mechanically that each side differed from the other by exactly
the one added symbol, so nothing else was hand-merged.
Nothing #7214 added was touched: the diff of this file against origin/main is
exactly this branch's own change — the i18n import line, the `viewDescription`
const, and the description render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

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

fix(grid,list,core): union a grid's grouping fields into the query projection - #7214

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection
Sep 1, 2026
Merged

fix(grid,list,core): union a grid's grouping fields into the query projection#7214
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7179

A grid view declaring grouping on a field absent from its columns rendered one group
labelled (empty) holding every row. $select was built from columns alone, so the
grouping field was never requested and was undefined on every row by the time grouping
ran. The grouping fields are now unioned into the projection — and into $expand — at
every site the projection is built.

What the dispatch assumed, and what the code actually says

The projection is built at TWO sites, not one. Both are on the grid's path and they
are independent, so a fix landing on one leaves the other blind:

sitewhen it runs
packages/plugin-grid/src/ObjectGrid.tsxgetSelectFields()the grid fetches for itself
packages/plugin-list/src/ListView.tsx — the selectFields IIFEListView fetches and hands the rows down to the grid

Both are fixed, and each has its own regression file so neither can silently regress
back to the other's behaviour.

"Three sibling adapters each union their groupByField" is not what the code does.
plugin-kanban, plugin-gantt and plugin-timeline contain no projection builder at
all
— a census of $select across their src/ returns zero hits. All three are served
by one shared arm, collectViewFields in ListView.tsx, which reads groupByField /
groupField / groupBy as plain strings. The grid was the only projection-blind path of
the four not because it lacked an adapter, but because it spells the same intent
differently: grouping.fields[], an array of objects, matching none of those candidate
keys. The correction stands — those three files are untouched here.

grouping.fields[] entries carry a plain string, confirmed against the spec.
GroupingFieldSchema in @objectstack/spec@17.2.0 is a $strict object of exactly
{ field: z.ZodString, order, collapsed }. No expression form, no nested-path form. A
bare-string shorthand is refused, deliberately: the schema does not accept it, and
useGroupedData reads f.field off each entry, so a bare string groups by undefined
no matter what the projection asks for. Reading it anyway would be the lenient
renderer-side alias AGENTS.md #0.1 forbids.

populate IS part of the fix. A select that fetches a bare foreign key without
expanding it buckets by raw id instead of by name — a different wrong answer, not a fix.
The expandFields memo in ListView.tsx already records this exact failure for kanban
in its own comment ("list view shows 'Initech Solutions' but kanban used to show
'8UY9zHWBfjYjYor4'"), and a grid grouped by a lookup landed in it. Grouping fields are
now unioned into $expand at both sites, pinned by its own test on each side.

The hazard, and why the union is gated

An unguarded union would have been strictly worse than the bug. A grouping.fields[]
entry has never been through column validation — that is the card's whole premise — and
some backends answer an unknown $select key with an empty result set rather than
ignoring it. Unioned raw, a grouping field naming a field the object does not declare
turns one (empty) group holding every row into zero rows, equally silently.

So grouping fields land on the speculative, gated side of both builders, never
alongside the known-valid columns:

  • Unknown-key gateisProjectableField in ObjectGrid, addSpeculative in
    ListView. A grouping field the object does not declare is dropped, and the rest of
    the projection survives intact (pinned in both directions: the unknown key is absent
    and the columns are still there).
  • Field-level securitypassesProjectionGate in ObjectGrid, and a new
    addGroupingField wrapper in ListView. A grouping field can name a denied field
    exactly as a column can, and the projection is the half that goes on the wire, so
    unioning after the FLS filter would have reopened objectui#6898 through a new door.
    projectionFls-6898.test.tsx is run here as a regression control.
  • $expand needs no unknown-key gate and deliberately does not get one:
    buildExpandFields returns a subset of the object's declared reference-bearing
    fields, so an unknown or non-relational grouping field is dropped structurally. Pinned
    anyway, so a later refactor cannot make that accidental.

ObjectGrid's fetch effect gains a groupingProjectionKey dep — a content key over the
grouping field names only. Grouping is runtime-mutable (the toolbar popover rewrites
it), so without it, switching the grouping field would leave the query asking for the old
one and the new grouping would read undefined on every row — the same (empty) bucket,
reachable a second way. Names only, because order and collapsed are render-time
concerns: collapsing a group must not cost a round trip.

Ablation

Direction and counts predicted before running; each leg's mutation proven on disk by
anchored marker count and blob hash; each restore proven by state (git diff HEAD,
git diff --cached, git status --short all empty) using git checkout HEAD -- with an
absolute path, never the bare form. The passing count is asserted alongside the red so a
collapsed population cannot pass for a clean run. Restore is trapped on EXIT INT TERM.

legmutationpredictedobserved
AObjectGrid projection union removed5 failed / 47 passed5 failed / 47 passed
BListView projection union removed4 failed / 48 passed4 failed / 48 passed
Cboth $expand augmentations removed2 failed / 50 passed2 failed / 50 passed

All three matched exactly. Leg A leaves the ListView file fully green and leg B leaves
the ObjectGrid file fully green — which is the measurement that shows the two sites are
genuinely independent, and that a one-site fix would have shipped looking correct.

Marker evidence per leg, e.g. leg A: removed-token 1 -> 0, injected-token 0 -> 1,
blob 0a3f9248 to 488b47ab. Restore confirmed both files byte-identical to their HEAD
blobs.

Verification

All runs below are on the final commit, 8006103ba.

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/core/
    272 files / 3846 tests passed, exit 0.
  • pnpm exec vitest run on the four suites that matter (the two new projection files,
    the new core unit file, and projectionFls-6898.test.tsx as regression control) —
    4 files / 52 tests passed, exit 0. File count asserted as the control that the
    right suites ran.
  • pnpm lint (the whole farm, eslint . --no-inline-config) — 47/47 tasks
    successful
    , exit 0. Not narrowed.
  • pnpm exec turbo run type-check for the three packages — 16/16 tasks successful,
    exit 0. Each package runs tsc --noEmit && tsc -p tsconfig.test.json, and all three
    new test files were confirmed present in the test program via --listFiles, so the
    green covers the tests and not just the sources.
  • Gates derived by hand from this repo's package.json and workflows (this repo has no
    scripts/pm/, and objectstack's dispatch-gates script answers only about its own
    tree): check:control-bytes, check:self-import, check:esm-specifiers,
    check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:side-effects-array, check:entry-guard, check-changeset-presence,
    check-changeset-no-majorall exit 0, each read from the gate's own printed
    verdict line rather than a bare exit status.
  • NOT MEASURED, reported as neither colour:check:readme-exports and
    check:eager-closure both require a full monorepo build this worktree does not have.
    Each says so itself — readme-exports reports "the population COLLAPSED, this run
    proves nothing" with 24 packages unbuilt, and eager-closure reports a missing
    apps/console/dist/eager-closure.json and calls itself "a broken gauge, not a
    sensitive gate". Neither reads any file in this diff (no README and no entry manifest
    is touched). CI builds before it runs them.

Exit codes throughout were captured by redirecting to a file first, never read after a
pipe.

Scope

The (empty) guard in useGroupedData.ts is untouched — it is correct for a
genuinely empty value and cannot distinguish it from a field that was never fetched. The
defect was upstream of it. sort is untouched, per the reporter recording it as
unmeasured rather than cleared. The kanban, gantt and timeline paths are untouched.

Out of scope, found while measuring

Not fixed here, and not yet filed — this seat's GitHub search returned
API rate limit already exceeded, so the mandatory duplicate check could not be run and
filing blind would risk duplicates. Handed to the PM in the structured report:

  1. A null entry in grouping.fields[] crashes the whole grid.ObjectGrid's
    groupValueFormatter memo dereferences gf.field with no null guard, throwing a
    TypeError before any projection is built. Confirmed pre-existing at this branch's
    base commit 4f596e01a. A different defect class from this card's silent wrong
    answer, so it is filed rather than ridden. The new harvester handles null correctly
    and that is pinned directly in the core unit test, which needs no grid to mount.
  2. $expand carries no FLS gate at either site.$select is FLS-gated on both
    paths, but buildExpandFields is handed a column list that was never filtered, so a
    denied lookup column is still expanded. Adjacent to objectui#6898 and affects columns
    generally, not grouping, so fixing it here would widen the review surface past this
    card.
  3. ListView's speculative view bindings are not FLS-gated. The kanban / gantt /
    timeline / calendar / gallery fields routed through addSpeculative are checked
    against the known-field set but never against checkField. The grouping field added
    by this PR is FLS-gated, so this PR does not extend the gap — it leaves an
    asymmetry worth closing deliberately rather than as a rider.

Generated by Claude Code


Generated by Claude Code

…ojection
A grid view declaring `grouping` on a field absent from its `columns` rendered
one group labelled `(empty)` holding every row. `$select` was built from the
view's `columns` alone, so the grouping field was never requested and was
`undefined` on every row by the time grouping ran.
The fix is in the projection, at both sites it is built (`ObjectGrid` when it
fetches for itself, `ListView` when it fetches for the grid), plus `$expand` so
a lookup grouping field groups by name rather than by raw foreign key.
The union is gated: grouping fields are intersected with the object's declared
fields and pass the same FLS gate as columns, because an unknown `$select` key
zeroes the whole list on backends that reject rather than ignore it.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3158.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D1fw90cR.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)207.50KB56.13KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.45KB27.64KB
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 at zero API quota (the seat is rate-limited): merge is clean against origin/main39d69ad53 (git merge-tree --write-tree exit 0), changeset present, 8 files.

The mid-flight correction was load-bearing, and the ablation is what proves it

My dispatch order asserted a single$select build site. That was wrong, and I sent the correction after grepping — but a correction is only a claim until it is measured. Legs A and B are the measurement:

leg A, ObjectGrid projection union removed → 5 failed / 47 passed. Leg A left the ListView file fully green.
leg B, ListView projection union removed → 4 failed / 48 passed. Leg B left the ObjectGrid file fully green.

That pair is the finding, not a formality: each site fails alone, so a one-site fix would have shipped with a green suite and a still-broken surface. All three legs matched their predicted counts exactly, with the PASSING count asserted alongside each red — so a collapsed population could not have passed for a clean run.

Both hazards I flagged are closed, and the reasoning is right

  • Unknown-key → zeroed list.isProjectableField intersects against the declared fields before anything reaches $select. The comment states the stake correctly: an unguarded union would have traded one (empty) group holding 186 rows for zero rows, equally silently — strictly worse, because the current bug at least shows the data.
  • FLS (objectui#6898).passesProjectionGate on the grid half, checkField on the list half, both applied before the union rather than after. The regression suite ran green as a control.
  • $expand was the right call to make unguarded, and for the stated structural reason — buildExpandFields returns a subset of the object's declared reference-bearing fields, so an unknown or non-relational entry is dropped by construction.

Three things I did not ask for and would have missed:

  1. $expand at all. My ZONE 2 asked whether populate was part of the fix and said to measure it. It is: without it a lookup grouping buckets by raw foreign key (8UY9zHWBfjYjYor4) instead of by name — better than one (empty) bucket, still wrong.
  2. The expandColumns ?? undefined conditional. Passing an array unconditionally would have narrowed the no-columns case from "every relation" to "the grouping fields alone". That is a regression the obvious patch introduces silently.
  3. groupingProjectionKey as a content key over the field NAMES only. Naming schema.grouping directly would have re-issued the query every render (hosts spread a fresh literal — the census(finding): three more renderer fetch effects key on a memoised object identity, outside objectui#6592's dataConfig family #6697 identity-churn storm), and keying the whole block would have cost a round trip every time a user collapsed a group. Without any dep, switching grouping at runtime would have left the query asking for the old field — reaching this card's own (empty) bucket by a second route.

⚠️ One real defect, non-blocking: a comment misidentifies its own guard

ListView.tsx, on addGroupingField:

Safe to ask checkField here precisely BECAUSE addSpeculative ran first — everything reaching this point is a field the object declares

addSpeculative(f) is the last statement in that function, not the first, and addGroupingField is called with the raw collectGroupingFieldRefs(groupingConfig) output, which nothing has validated. The claim as written is false.

The code is correctcheckField is genuinely unreachable for an undeclared key, because knownObjectFields?.has(f) is an earlier conjunct in the same && chain and short-circuits. So the safety property holds; the comment just credits the wrong guard.

Not blocking, and not worth a CI cycle on its own. But worth fixing on the next touch of this file, because the failure mode is specific: a later refactor that trusts this comment could drop the knownObjectFields?.has(f) conjunct believing declaredness was already guaranteed upstream. This repo treats a comment that misdescribes its own mechanism as a real finding — #7204 is open right now for exactly that shape.

Discipline worth naming

NOT MEASURED was reported as neither red nor green.check:readme-exports and check:eager-closure both need a full monorepo build this worktree does not have, and each says so in its own words ("the population COLLAPSED, this run proves nothing"; "a broken gauge, not a sensitive gate"). Neither reads a file in this diff. Reporting those as green would have been the easy lie.

And the rate-limit trap was refused. The duplicate check for a new finding could not run, so the finding was reported as FILING BLOCKED rather than filed or dropped — with the sharp part recorded: /rate_limitanswered, reporting core 14152 and graphql 10000 remaining, and those numbers are not evidence the channel is open. Acting on them would have been the trap. That is the same "I read a result" ≠ "I measured" shape as #7089 §6.

The four out-of-scope findings are the PM's to file, including two FLS gaps that are security-adjacent. Filing them as quota allows; they are recorded here so they are not lost if this seat ends first.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 13:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a6d8b8dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7179-grid-grouping-projection branch September 1, 2026 14:17
os-warren pushed a commit that referenced this pull request Sep 1, 2026
Textual conflict only, in the two adjacent import statements at the top of
packages/plugin-list/src/ListView.tsx:
- main (#7214) extended the `@object-ui/core` import with `collectGroupingFieldRefs`
- this branch extended the `@object-ui/i18n` import with `pickLocalized`
Git folded the two adjacent lines into one hunk and could not take both sides.
Resolved by keeping BOTH: main's core import verbatim (so #7214's
`collectGroupingFieldRefs` still resolves at both projection call sites) and this
branch's i18n import (so `pickLocalized` still resolves at the description read
site). Verified mechanically that each side differed from the other by exactly
the one added symbol, so nothing else was hand-merged.
Nothing #7214 added was touched: the diff of this file against origin/main is
exactly this branch's own change — the i18n import line, the `viewDescription`
const, and the description render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

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

fix(grid,list,core): union a grid's grouping fields into the query projection - #7214

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection
Sep 1, 2026
Merged

fix(grid,list,core): union a grid's grouping fields into the query projection#7214
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7179

A grid view declaring grouping on a field absent from its columns rendered one group
labelled (empty) holding every row. $select was built from columns alone, so the
grouping field was never requested and was undefined on every row by the time grouping
ran. The grouping fields are now unioned into the projection — and into $expand — at
every site the projection is built.

What the dispatch assumed, and what the code actually says

The projection is built at TWO sites, not one. Both are on the grid's path and they
are independent, so a fix landing on one leaves the other blind:

sitewhen it runs
packages/plugin-grid/src/ObjectGrid.tsxgetSelectFields()the grid fetches for itself
packages/plugin-list/src/ListView.tsx — the selectFields IIFEListView fetches and hands the rows down to the grid

Both are fixed, and each has its own regression file so neither can silently regress
back to the other's behaviour.

"Three sibling adapters each union their groupByField" is not what the code does.
plugin-kanban, plugin-gantt and plugin-timeline contain no projection builder at
all
— a census of $select across their src/ returns zero hits. All three are served
by one shared arm, collectViewFields in ListView.tsx, which reads groupByField /
groupField / groupBy as plain strings. The grid was the only projection-blind path of
the four not because it lacked an adapter, but because it spells the same intent
differently: grouping.fields[], an array of objects, matching none of those candidate
keys. The correction stands — those three files are untouched here.

grouping.fields[] entries carry a plain string, confirmed against the spec.
GroupingFieldSchema in @objectstack/spec@17.2.0 is a $strict object of exactly
{ field: z.ZodString, order, collapsed }. No expression form, no nested-path form. A
bare-string shorthand is refused, deliberately: the schema does not accept it, and
useGroupedData reads f.field off each entry, so a bare string groups by undefined
no matter what the projection asks for. Reading it anyway would be the lenient
renderer-side alias AGENTS.md #0.1 forbids.

populate IS part of the fix. A select that fetches a bare foreign key without
expanding it buckets by raw id instead of by name — a different wrong answer, not a fix.
The expandFields memo in ListView.tsx already records this exact failure for kanban
in its own comment ("list view shows 'Initech Solutions' but kanban used to show
'8UY9zHWBfjYjYor4'"), and a grid grouped by a lookup landed in it. Grouping fields are
now unioned into $expand at both sites, pinned by its own test on each side.

The hazard, and why the union is gated

An unguarded union would have been strictly worse than the bug. A grouping.fields[]
entry has never been through column validation — that is the card's whole premise — and
some backends answer an unknown $select key with an empty result set rather than
ignoring it. Unioned raw, a grouping field naming a field the object does not declare
turns one (empty) group holding every row into zero rows, equally silently.

So grouping fields land on the speculative, gated side of both builders, never
alongside the known-valid columns:

  • Unknown-key gateisProjectableField in ObjectGrid, addSpeculative in
    ListView. A grouping field the object does not declare is dropped, and the rest of
    the projection survives intact (pinned in both directions: the unknown key is absent
    and the columns are still there).
  • Field-level securitypassesProjectionGate in ObjectGrid, and a new
    addGroupingField wrapper in ListView. A grouping field can name a denied field
    exactly as a column can, and the projection is the half that goes on the wire, so
    unioning after the FLS filter would have reopened objectui#6898 through a new door.
    projectionFls-6898.test.tsx is run here as a regression control.
  • $expand needs no unknown-key gate and deliberately does not get one:
    buildExpandFields returns a subset of the object's declared reference-bearing
    fields, so an unknown or non-relational grouping field is dropped structurally. Pinned
    anyway, so a later refactor cannot make that accidental.

ObjectGrid's fetch effect gains a groupingProjectionKey dep — a content key over the
grouping field names only. Grouping is runtime-mutable (the toolbar popover rewrites
it), so without it, switching the grouping field would leave the query asking for the old
one and the new grouping would read undefined on every row — the same (empty) bucket,
reachable a second way. Names only, because order and collapsed are render-time
concerns: collapsing a group must not cost a round trip.

Ablation

Direction and counts predicted before running; each leg's mutation proven on disk by
anchored marker count and blob hash; each restore proven by state (git diff HEAD,
git diff --cached, git status --short all empty) using git checkout HEAD -- with an
absolute path, never the bare form. The passing count is asserted alongside the red so a
collapsed population cannot pass for a clean run. Restore is trapped on EXIT INT TERM.

legmutationpredictedobserved
AObjectGrid projection union removed5 failed / 47 passed5 failed / 47 passed
BListView projection union removed4 failed / 48 passed4 failed / 48 passed
Cboth $expand augmentations removed2 failed / 50 passed2 failed / 50 passed

All three matched exactly. Leg A leaves the ListView file fully green and leg B leaves
the ObjectGrid file fully green — which is the measurement that shows the two sites are
genuinely independent, and that a one-site fix would have shipped looking correct.

Marker evidence per leg, e.g. leg A: removed-token 1 -> 0, injected-token 0 -> 1,
blob 0a3f9248 to 488b47ab. Restore confirmed both files byte-identical to their HEAD
blobs.

Verification

All runs below are on the final commit, 8006103ba.

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/core/
    272 files / 3846 tests passed, exit 0.
  • pnpm exec vitest run on the four suites that matter (the two new projection files,
    the new core unit file, and projectionFls-6898.test.tsx as regression control) —
    4 files / 52 tests passed, exit 0. File count asserted as the control that the
    right suites ran.
  • pnpm lint (the whole farm, eslint . --no-inline-config) — 47/47 tasks
    successful
    , exit 0. Not narrowed.
  • pnpm exec turbo run type-check for the three packages — 16/16 tasks successful,
    exit 0. Each package runs tsc --noEmit && tsc -p tsconfig.test.json, and all three
    new test files were confirmed present in the test program via --listFiles, so the
    green covers the tests and not just the sources.
  • Gates derived by hand from this repo's package.json and workflows (this repo has no
    scripts/pm/, and objectstack's dispatch-gates script answers only about its own
    tree): check:control-bytes, check:self-import, check:esm-specifiers,
    check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:side-effects-array, check:entry-guard, check-changeset-presence,
    check-changeset-no-majorall exit 0, each read from the gate's own printed
    verdict line rather than a bare exit status.
  • NOT MEASURED, reported as neither colour:check:readme-exports and
    check:eager-closure both require a full monorepo build this worktree does not have.
    Each says so itself — readme-exports reports "the population COLLAPSED, this run
    proves nothing" with 24 packages unbuilt, and eager-closure reports a missing
    apps/console/dist/eager-closure.json and calls itself "a broken gauge, not a
    sensitive gate". Neither reads any file in this diff (no README and no entry manifest
    is touched). CI builds before it runs them.

Exit codes throughout were captured by redirecting to a file first, never read after a
pipe.

Scope

The (empty) guard in useGroupedData.ts is untouched — it is correct for a
genuinely empty value and cannot distinguish it from a field that was never fetched. The
defect was upstream of it. sort is untouched, per the reporter recording it as
unmeasured rather than cleared. The kanban, gantt and timeline paths are untouched.

Out of scope, found while measuring

Not fixed here, and not yet filed — this seat's GitHub search returned
API rate limit already exceeded, so the mandatory duplicate check could not be run and
filing blind would risk duplicates. Handed to the PM in the structured report:

  1. A null entry in grouping.fields[] crashes the whole grid.ObjectGrid's
    groupValueFormatter memo dereferences gf.field with no null guard, throwing a
    TypeError before any projection is built. Confirmed pre-existing at this branch's
    base commit 4f596e01a. A different defect class from this card's silent wrong
    answer, so it is filed rather than ridden. The new harvester handles null correctly
    and that is pinned directly in the core unit test, which needs no grid to mount.
  2. $expand carries no FLS gate at either site.$select is FLS-gated on both
    paths, but buildExpandFields is handed a column list that was never filtered, so a
    denied lookup column is still expanded. Adjacent to objectui#6898 and affects columns
    generally, not grouping, so fixing it here would widen the review surface past this
    card.
  3. ListView's speculative view bindings are not FLS-gated. The kanban / gantt /
    timeline / calendar / gallery fields routed through addSpeculative are checked
    against the known-field set but never against checkField. The grouping field added
    by this PR is FLS-gated, so this PR does not extend the gap — it leaves an
    asymmetry worth closing deliberately rather than as a rider.

Generated by Claude Code


Generated by Claude Code

…ojection
A grid view declaring `grouping` on a field absent from its `columns` rendered
one group labelled `(empty)` holding every row. `$select` was built from the
view's `columns` alone, so the grouping field was never requested and was
`undefined` on every row by the time grouping ran.
The fix is in the projection, at both sites it is built (`ObjectGrid` when it
fetches for itself, `ListView` when it fetches for the grid), plus `$expand` so
a lookup grouping field groups by name rather than by raw foreign key.
The union is gated: grouping fields are intersected with the object's declared
fields and pass the same FLS gate as columns, because an unknown `$select` key
zeroes the whole list on backends that reject rather than ignore it.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3158.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D1fw90cR.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)207.50KB56.13KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.45KB27.64KB
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 at zero API quota (the seat is rate-limited): merge is clean against origin/main39d69ad53 (git merge-tree --write-tree exit 0), changeset present, 8 files.

The mid-flight correction was load-bearing, and the ablation is what proves it

My dispatch order asserted a single$select build site. That was wrong, and I sent the correction after grepping — but a correction is only a claim until it is measured. Legs A and B are the measurement:

leg A, ObjectGrid projection union removed → 5 failed / 47 passed. Leg A left the ListView file fully green.
leg B, ListView projection union removed → 4 failed / 48 passed. Leg B left the ObjectGrid file fully green.

That pair is the finding, not a formality: each site fails alone, so a one-site fix would have shipped with a green suite and a still-broken surface. All three legs matched their predicted counts exactly, with the PASSING count asserted alongside each red — so a collapsed population could not have passed for a clean run.

Both hazards I flagged are closed, and the reasoning is right

  • Unknown-key → zeroed list.isProjectableField intersects against the declared fields before anything reaches $select. The comment states the stake correctly: an unguarded union would have traded one (empty) group holding 186 rows for zero rows, equally silently — strictly worse, because the current bug at least shows the data.
  • FLS (objectui#6898).passesProjectionGate on the grid half, checkField on the list half, both applied before the union rather than after. The regression suite ran green as a control.
  • $expand was the right call to make unguarded, and for the stated structural reason — buildExpandFields returns a subset of the object's declared reference-bearing fields, so an unknown or non-relational entry is dropped by construction.

Three things I did not ask for and would have missed:

  1. $expand at all. My ZONE 2 asked whether populate was part of the fix and said to measure it. It is: without it a lookup grouping buckets by raw foreign key (8UY9zHWBfjYjYor4) instead of by name — better than one (empty) bucket, still wrong.
  2. The expandColumns ?? undefined conditional. Passing an array unconditionally would have narrowed the no-columns case from "every relation" to "the grouping fields alone". That is a regression the obvious patch introduces silently.
  3. groupingProjectionKey as a content key over the field NAMES only. Naming schema.grouping directly would have re-issued the query every render (hosts spread a fresh literal — the census(finding): three more renderer fetch effects key on a memoised object identity, outside objectui#6592's dataConfig family #6697 identity-churn storm), and keying the whole block would have cost a round trip every time a user collapsed a group. Without any dep, switching grouping at runtime would have left the query asking for the old field — reaching this card's own (empty) bucket by a second route.

⚠️ One real defect, non-blocking: a comment misidentifies its own guard

ListView.tsx, on addGroupingField:

Safe to ask checkField here precisely BECAUSE addSpeculative ran first — everything reaching this point is a field the object declares

addSpeculative(f) is the last statement in that function, not the first, and addGroupingField is called with the raw collectGroupingFieldRefs(groupingConfig) output, which nothing has validated. The claim as written is false.

The code is correctcheckField is genuinely unreachable for an undeclared key, because knownObjectFields?.has(f) is an earlier conjunct in the same && chain and short-circuits. So the safety property holds; the comment just credits the wrong guard.

Not blocking, and not worth a CI cycle on its own. But worth fixing on the next touch of this file, because the failure mode is specific: a later refactor that trusts this comment could drop the knownObjectFields?.has(f) conjunct believing declaredness was already guaranteed upstream. This repo treats a comment that misdescribes its own mechanism as a real finding — #7204 is open right now for exactly that shape.

Discipline worth naming

NOT MEASURED was reported as neither red nor green.check:readme-exports and check:eager-closure both need a full monorepo build this worktree does not have, and each says so in its own words ("the population COLLAPSED, this run proves nothing"; "a broken gauge, not a sensitive gate"). Neither reads a file in this diff. Reporting those as green would have been the easy lie.

And the rate-limit trap was refused. The duplicate check for a new finding could not run, so the finding was reported as FILING BLOCKED rather than filed or dropped — with the sharp part recorded: /rate_limitanswered, reporting core 14152 and graphql 10000 remaining, and those numbers are not evidence the channel is open. Acting on them would have been the trap. That is the same "I read a result" ≠ "I measured" shape as #7089 §6.

The four out-of-scope findings are the PM's to file, including two FLS gaps that are security-adjacent. Filing them as quota allows; they are recorded here so they are not lost if this seat ends first.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 13:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a6d8b8dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7179-grid-grouping-projection branch September 1, 2026 14:17
os-warren pushed a commit that referenced this pull request Sep 1, 2026
Textual conflict only, in the two adjacent import statements at the top of
packages/plugin-list/src/ListView.tsx:
- main (#7214) extended the `@object-ui/core` import with `collectGroupingFieldRefs`
- this branch extended the `@object-ui/i18n` import with `pickLocalized`
Git folded the two adjacent lines into one hunk and could not take both sides.
Resolved by keeping BOTH: main's core import verbatim (so #7214's
`collectGroupingFieldRefs` still resolves at both projection call sites) and this
branch's i18n import (so `pickLocalized` still resolves at the description read
site). Verified mechanically that each side differed from the other by exactly
the one added symbol, so nothing else was hand-merged.
Nothing #7214 added was touched: the diff of this file against origin/main is
exactly this branch's own change — the i18n import line, the `viewDescription`
const, and the description render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

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

fix(grid,list,core): union a grid's grouping fields into the query projection - #7214

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection
Sep 1, 2026
Merged

fix(grid,list,core): union a grid's grouping fields into the query projection#7214
os-warren merged 1 commit into
mainfrom
claude/issue-7179-grid-grouping-projection

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7179

A grid view declaring grouping on a field absent from its columns rendered one group
labelled (empty) holding every row. $select was built from columns alone, so the
grouping field was never requested and was undefined on every row by the time grouping
ran. The grouping fields are now unioned into the projection — and into $expand — at
every site the projection is built.

What the dispatch assumed, and what the code actually says

The projection is built at TWO sites, not one. Both are on the grid's path and they
are independent, so a fix landing on one leaves the other blind:

sitewhen it runs
packages/plugin-grid/src/ObjectGrid.tsxgetSelectFields()the grid fetches for itself
packages/plugin-list/src/ListView.tsx — the selectFields IIFEListView fetches and hands the rows down to the grid

Both are fixed, and each has its own regression file so neither can silently regress
back to the other's behaviour.

"Three sibling adapters each union their groupByField" is not what the code does.
plugin-kanban, plugin-gantt and plugin-timeline contain no projection builder at
all
— a census of $select across their src/ returns zero hits. All three are served
by one shared arm, collectViewFields in ListView.tsx, which reads groupByField /
groupField / groupBy as plain strings. The grid was the only projection-blind path of
the four not because it lacked an adapter, but because it spells the same intent
differently: grouping.fields[], an array of objects, matching none of those candidate
keys. The correction stands — those three files are untouched here.

grouping.fields[] entries carry a plain string, confirmed against the spec.
GroupingFieldSchema in @objectstack/spec@17.2.0 is a $strict object of exactly
{ field: z.ZodString, order, collapsed }. No expression form, no nested-path form. A
bare-string shorthand is refused, deliberately: the schema does not accept it, and
useGroupedData reads f.field off each entry, so a bare string groups by undefined
no matter what the projection asks for. Reading it anyway would be the lenient
renderer-side alias AGENTS.md #0.1 forbids.

populate IS part of the fix. A select that fetches a bare foreign key without
expanding it buckets by raw id instead of by name — a different wrong answer, not a fix.
The expandFields memo in ListView.tsx already records this exact failure for kanban
in its own comment ("list view shows 'Initech Solutions' but kanban used to show
'8UY9zHWBfjYjYor4'"), and a grid grouped by a lookup landed in it. Grouping fields are
now unioned into $expand at both sites, pinned by its own test on each side.

The hazard, and why the union is gated

An unguarded union would have been strictly worse than the bug. A grouping.fields[]
entry has never been through column validation — that is the card's whole premise — and
some backends answer an unknown $select key with an empty result set rather than
ignoring it. Unioned raw, a grouping field naming a field the object does not declare
turns one (empty) group holding every row into zero rows, equally silently.

So grouping fields land on the speculative, gated side of both builders, never
alongside the known-valid columns:

  • Unknown-key gateisProjectableField in ObjectGrid, addSpeculative in
    ListView. A grouping field the object does not declare is dropped, and the rest of
    the projection survives intact (pinned in both directions: the unknown key is absent
    and the columns are still there).
  • Field-level securitypassesProjectionGate in ObjectGrid, and a new
    addGroupingField wrapper in ListView. A grouping field can name a denied field
    exactly as a column can, and the projection is the half that goes on the wire, so
    unioning after the FLS filter would have reopened objectui#6898 through a new door.
    projectionFls-6898.test.tsx is run here as a regression control.
  • $expand needs no unknown-key gate and deliberately does not get one:
    buildExpandFields returns a subset of the object's declared reference-bearing
    fields, so an unknown or non-relational grouping field is dropped structurally. Pinned
    anyway, so a later refactor cannot make that accidental.

ObjectGrid's fetch effect gains a groupingProjectionKey dep — a content key over the
grouping field names only. Grouping is runtime-mutable (the toolbar popover rewrites
it), so without it, switching the grouping field would leave the query asking for the old
one and the new grouping would read undefined on every row — the same (empty) bucket,
reachable a second way. Names only, because order and collapsed are render-time
concerns: collapsing a group must not cost a round trip.

Ablation

Direction and counts predicted before running; each leg's mutation proven on disk by
anchored marker count and blob hash; each restore proven by state (git diff HEAD,
git diff --cached, git status --short all empty) using git checkout HEAD -- with an
absolute path, never the bare form. The passing count is asserted alongside the red so a
collapsed population cannot pass for a clean run. Restore is trapped on EXIT INT TERM.

legmutationpredictedobserved
AObjectGrid projection union removed5 failed / 47 passed5 failed / 47 passed
BListView projection union removed4 failed / 48 passed4 failed / 48 passed
Cboth $expand augmentations removed2 failed / 50 passed2 failed / 50 passed

All three matched exactly. Leg A leaves the ListView file fully green and leg B leaves
the ObjectGrid file fully green — which is the measurement that shows the two sites are
genuinely independent, and that a one-site fix would have shipped looking correct.

Marker evidence per leg, e.g. leg A: removed-token 1 -> 0, injected-token 0 -> 1,
blob 0a3f9248 to 488b47ab. Restore confirmed both files byte-identical to their HEAD
blobs.

Verification

All runs below are on the final commit, 8006103ba.

  • pnpm exec vitest run packages/plugin-grid/ packages/plugin-list/ packages/core/
    272 files / 3846 tests passed, exit 0.
  • pnpm exec vitest run on the four suites that matter (the two new projection files,
    the new core unit file, and projectionFls-6898.test.tsx as regression control) —
    4 files / 52 tests passed, exit 0. File count asserted as the control that the
    right suites ran.
  • pnpm lint (the whole farm, eslint . --no-inline-config) — 47/47 tasks
    successful
    , exit 0. Not narrowed.
  • pnpm exec turbo run type-check for the three packages — 16/16 tasks successful,
    exit 0. Each package runs tsc --noEmit && tsc -p tsconfig.test.json, and all three
    new test files were confirmed present in the test program via --listFiles, so the
    green covers the tests and not just the sources.
  • Gates derived by hand from this repo's package.json and workflows (this repo has no
    scripts/pm/, and objectstack's dispatch-gates script answers only about its own
    tree): check:control-bytes, check:self-import, check:esm-specifiers,
    check:phantom-deps, check:vi-mock-specifiers, check:vi-mock-inherit,
    check:side-effects-array, check:entry-guard, check-changeset-presence,
    check-changeset-no-majorall exit 0, each read from the gate's own printed
    verdict line rather than a bare exit status.
  • NOT MEASURED, reported as neither colour:check:readme-exports and
    check:eager-closure both require a full monorepo build this worktree does not have.
    Each says so itself — readme-exports reports "the population COLLAPSED, this run
    proves nothing" with 24 packages unbuilt, and eager-closure reports a missing
    apps/console/dist/eager-closure.json and calls itself "a broken gauge, not a
    sensitive gate". Neither reads any file in this diff (no README and no entry manifest
    is touched). CI builds before it runs them.

Exit codes throughout were captured by redirecting to a file first, never read after a
pipe.

Scope

The (empty) guard in useGroupedData.ts is untouched — it is correct for a
genuinely empty value and cannot distinguish it from a field that was never fetched. The
defect was upstream of it. sort is untouched, per the reporter recording it as
unmeasured rather than cleared. The kanban, gantt and timeline paths are untouched.

Out of scope, found while measuring

Not fixed here, and not yet filed — this seat's GitHub search returned
API rate limit already exceeded, so the mandatory duplicate check could not be run and
filing blind would risk duplicates. Handed to the PM in the structured report:

  1. A null entry in grouping.fields[] crashes the whole grid.ObjectGrid's
    groupValueFormatter memo dereferences gf.field with no null guard, throwing a
    TypeError before any projection is built. Confirmed pre-existing at this branch's
    base commit 4f596e01a. A different defect class from this card's silent wrong
    answer, so it is filed rather than ridden. The new harvester handles null correctly
    and that is pinned directly in the core unit test, which needs no grid to mount.
  2. $expand carries no FLS gate at either site.$select is FLS-gated on both
    paths, but buildExpandFields is handed a column list that was never filtered, so a
    denied lookup column is still expanded. Adjacent to objectui#6898 and affects columns
    generally, not grouping, so fixing it here would widen the review surface past this
    card.
  3. ListView's speculative view bindings are not FLS-gated. The kanban / gantt /
    timeline / calendar / gallery fields routed through addSpeculative are checked
    against the known-field set but never against checkField. The grouping field added
    by this PR is FLS-gated, so this PR does not extend the gap — it leaves an
    asymmetry worth closing deliberately rather than as a rider.

Generated by Claude Code


Generated by Claude Code

…ojection
A grid view declaring `grouping` on a field absent from its `columns` rendered
one group labelled `(empty)` holding every row. `$select` was built from the
view's `columns` alone, so the grouping field was never requested and was
`undefined` on every row by the time grouping ran.
The fix is in the projection, at both sites it is built (`ObjectGrid` when it
fetches for itself, `ListView` when it fetches for the grid), plus `$expand` so
a lookup grouping field groups by name rather than by raw foreign key.
The union is gated: grouping fields are intersected with the object's declared
fields and pass the same FLS gate as columns, because an unknown `$select` key
zeroes the whole list on backends that reject rather than ignore it.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3158.1 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D1fw90cR.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)207.50KB56.13KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.45KB27.64KB
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 at zero API quota (the seat is rate-limited): merge is clean against origin/main39d69ad53 (git merge-tree --write-tree exit 0), changeset present, 8 files.

The mid-flight correction was load-bearing, and the ablation is what proves it

My dispatch order asserted a single$select build site. That was wrong, and I sent the correction after grepping — but a correction is only a claim until it is measured. Legs A and B are the measurement:

leg A, ObjectGrid projection union removed → 5 failed / 47 passed. Leg A left the ListView file fully green.
leg B, ListView projection union removed → 4 failed / 48 passed. Leg B left the ObjectGrid file fully green.

That pair is the finding, not a formality: each site fails alone, so a one-site fix would have shipped with a green suite and a still-broken surface. All three legs matched their predicted counts exactly, with the PASSING count asserted alongside each red — so a collapsed population could not have passed for a clean run.

Both hazards I flagged are closed, and the reasoning is right

  • Unknown-key → zeroed list.isProjectableField intersects against the declared fields before anything reaches $select. The comment states the stake correctly: an unguarded union would have traded one (empty) group holding 186 rows for zero rows, equally silently — strictly worse, because the current bug at least shows the data.
  • FLS (objectui#6898).passesProjectionGate on the grid half, checkField on the list half, both applied before the union rather than after. The regression suite ran green as a control.
  • $expand was the right call to make unguarded, and for the stated structural reason — buildExpandFields returns a subset of the object's declared reference-bearing fields, so an unknown or non-relational entry is dropped by construction.

Three things I did not ask for and would have missed:

  1. $expand at all. My ZONE 2 asked whether populate was part of the fix and said to measure it. It is: without it a lookup grouping buckets by raw foreign key (8UY9zHWBfjYjYor4) instead of by name — better than one (empty) bucket, still wrong.
  2. The expandColumns ?? undefined conditional. Passing an array unconditionally would have narrowed the no-columns case from "every relation" to "the grouping fields alone". That is a regression the obvious patch introduces silently.
  3. groupingProjectionKey as a content key over the field NAMES only. Naming schema.grouping directly would have re-issued the query every render (hosts spread a fresh literal — the census(finding): three more renderer fetch effects key on a memoised object identity, outside objectui#6592's dataConfig family #6697 identity-churn storm), and keying the whole block would have cost a round trip every time a user collapsed a group. Without any dep, switching grouping at runtime would have left the query asking for the old field — reaching this card's own (empty) bucket by a second route.

⚠️ One real defect, non-blocking: a comment misidentifies its own guard

ListView.tsx, on addGroupingField:

Safe to ask checkField here precisely BECAUSE addSpeculative ran first — everything reaching this point is a field the object declares

addSpeculative(f) is the last statement in that function, not the first, and addGroupingField is called with the raw collectGroupingFieldRefs(groupingConfig) output, which nothing has validated. The claim as written is false.

The code is correctcheckField is genuinely unreachable for an undeclared key, because knownObjectFields?.has(f) is an earlier conjunct in the same && chain and short-circuits. So the safety property holds; the comment just credits the wrong guard.

Not blocking, and not worth a CI cycle on its own. But worth fixing on the next touch of this file, because the failure mode is specific: a later refactor that trusts this comment could drop the knownObjectFields?.has(f) conjunct believing declaredness was already guaranteed upstream. This repo treats a comment that misdescribes its own mechanism as a real finding — #7204 is open right now for exactly that shape.

Discipline worth naming

NOT MEASURED was reported as neither red nor green.check:readme-exports and check:eager-closure both need a full monorepo build this worktree does not have, and each says so in its own words ("the population COLLAPSED, this run proves nothing"; "a broken gauge, not a sensitive gate"). Neither reads a file in this diff. Reporting those as green would have been the easy lie.

And the rate-limit trap was refused. The duplicate check for a new finding could not run, so the finding was reported as FILING BLOCKED rather than filed or dropped — with the sharp part recorded: /rate_limitanswered, reporting core 14152 and graphql 10000 remaining, and those numbers are not evidence the channel is open. Acting on them would have been the trap. That is the same "I read a result" ≠ "I measured" shape as #7089 §6.

The four out-of-scope findings are the PM's to file, including two FLS gaps that are security-adjacent. Filing them as quota allows; they are recorded here so they are not lost if this seat ends first.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 13:54
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit a6d8b8dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7179-grid-grouping-projection branch September 1, 2026 14:17
os-warren pushed a commit that referenced this pull request Sep 1, 2026
Textual conflict only, in the two adjacent import statements at the top of
packages/plugin-list/src/ListView.tsx:
- main (#7214) extended the `@object-ui/core` import with `collectGroupingFieldRefs`
- this branch extended the `@object-ui/i18n` import with `pickLocalized`
Git folded the two adjacent lines into one hunk and could not take both sides.
Resolved by keeping BOTH: main's core import verbatim (so #7214's
`collectGroupingFieldRefs` still resolves at both projection call sites) and this
branch's i18n import (so `pickLocalized` still resolves at the description read
site). Verified mechanically that each side differed from the other by exactly
the one added symbol, so nothing else was hand-merged.
Nothing #7214 added was touched: the diff of this file against origin/main is
exactly this branch's own change — the i18n import line, the `viewDescription`
const, and the description render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-warren@claude