Found by opening a grouped grid in a real ObjectStack application (objectstack-ai/duly) against published @objectstack/* 17.2.0, with 186 real records across 5 business units. Network payloads captured; root cause confirmed in this repo's source.
The claim
A grid view that declares grouping on a field absent from its columns renders one group labelled (empty) containing every row. No error, no warning, no empty state — a grouped grid that looks like it grouped, and did not.
The grouping field is fully populated on every record. The same field, grouped by a dashboard chart on the same data, resolves its labels correctly.
Measured
View declaration:
by_unit: {
type: 'grid',
columns, // subject, status, due_date, period_key, owner, source
grouping: { fields: [{ field: 'business_unit' }] },
}
Rendered: a single collapsible group, (empty) 100, holding every row.
Two payloads captured on that page load:
GET /api/v1/data/duly_task?
keys: id,…,subject,duty,owner,business_unit,assignment,source,period_key,…
business_unit: "Q2UJSlOy4ICnWmTg" ← populated
GET /api/v1/data/duly_task?populate=owner&top=100&select=id,subject,status,due_date,period_key,…
keys: id,subject,status,due_date,period_key,owner,source
business_unit: undefined ← not requested
The grid's own request builds select from the view's columns and nothing else. business_unit is never asked for, so it is undefined on every row by the time grouping runs.
Storage confirms the data is not the problem — select business_unit, count(*) from duly_task group by 1 gives 5 distinct unit ids over 186 rows, 0 nulls.
Why it lands on (empty) rather than failing
packages/plugin-grid/src/useGroupedData.ts:119, first line of buildSegmentLabel:
if (value === undefined || value === null || value === '') return '(empty)';
That guard is right for a genuinely empty value and cannot distinguish it from a field that was never fetched. Everything below it handles lookups well — an expanded { id, name } resolves through name/label/title/…, and a bare FK string falls through to String(value), which would at least have shown five distinct id buckets. Neither path is reached, because the value is undefined before any of it runs.
So the defect is upstream of the label builder, in the projection: the query should include every field the view groups by.
Why this is worth prioritising
It is a silent wrong answer, not a missing feature, and it is invisible in exactly the conditions authors test under:
objectstack validate and build are green — business_unit is a real field on the object, so every binding check passes;
- the view renders, the row count is right, the rows are right;
- the only symptom is that the grouping did nothing, and
(empty) reads as "these records have no unit" — a plausible, wrong, actionable conclusion. A manager looking at this concludes the org data is unpopulated.
An author whose grouping field happens to also be a displayed column never sees it, which is why this survives casual use.
Reproduce
// any object with a populated field `f`
{ type: 'grid',
columns: [{ field: 'a' }, { field: 'b' }], // note: no `f`
grouping: { fields: [{ field: 'f' }] } }
Open the view. One group, (empty), all rows. Add { field: 'f' } to columns and it groups correctly.
Suggested direction
Union the grouping fields into the select projection where it is built, rather than requiring authors to mirror them in columns — grouping by a field you do not want as a visible column is an ordinary thing to want, and it is what the two config keys being separate implies is supported.
Worth checking the same builder for the neighbouring cases before closing: a sort on a non-column field, and the groupBy fields on kanban and gantt, all take a field name from a config key that is not columns.
If the projection is deliberately columns-only, then the author-time gate is the alternative — refuse a grouping.fields[] entry that names no column — but the projection fix is the one that matches what the schema already lets you write.
Application impact
In the app that found this, the affected view is the manager's org lens: "show me what is outstanding, by business unit". It shipped green, with a passing metadata-binding test that resolves business_unit against the object schema — which it does resolve, correctly, and which is exactly why the test could not catch this.
Found by opening a grouped grid in a real ObjectStack application (
objectstack-ai/duly) against published@objectstack/*17.2.0, with 186 real records across 5 business units. Network payloads captured; root cause confirmed in this repo's source.The claim
A grid view that declares
groupingon a field absent from itscolumnsrenders one group labelled(empty)containing every row. No error, no warning, no empty state — a grouped grid that looks like it grouped, and did not.The grouping field is fully populated on every record. The same field, grouped by a dashboard chart on the same data, resolves its labels correctly.
Measured
View declaration:
Rendered: a single collapsible group,
(empty) 100, holding every row.Two payloads captured on that page load:
The grid's own request builds
selectfrom the view'scolumnsand nothing else.business_unitis never asked for, so it isundefinedon every row by the time grouping runs.Storage confirms the data is not the problem —
select business_unit, count(*) from duly_task group by 1gives 5 distinct unit ids over 186 rows, 0 nulls.Why it lands on
(empty)rather than failingpackages/plugin-grid/src/useGroupedData.ts:119, first line ofbuildSegmentLabel:That guard is right for a genuinely empty value and cannot distinguish it from a field that was never fetched. Everything below it handles lookups well — an expanded
{ id, name }resolves throughname/label/title/…, and a bare FK string falls through toString(value), which would at least have shown five distinct id buckets. Neither path is reached, because the value isundefinedbefore any of it runs.So the defect is upstream of the label builder, in the projection: the query should include every field the view groups by.
Why this is worth prioritising
It is a silent wrong answer, not a missing feature, and it is invisible in exactly the conditions authors test under:
objectstack validateandbuildare green —business_unitis a real field on the object, so every binding check passes;(empty)reads as "these records have no unit" — a plausible, wrong, actionable conclusion. A manager looking at this concludes the org data is unpopulated.An author whose grouping field happens to also be a displayed column never sees it, which is why this survives casual use.
Reproduce
Open the view. One group,
(empty), all rows. Add{ field: 'f' }tocolumnsand it groups correctly.Suggested direction
Union the grouping fields into the
selectprojection where it is built, rather than requiring authors to mirror them incolumns— grouping by a field you do not want as a visible column is an ordinary thing to want, and it is what the two config keys being separate implies is supported.Worth checking the same builder for the neighbouring cases before closing: a
sorton a non-column field, and thegroupByfields on kanban and gantt, all take a field name from a config key that is notcolumns.If the projection is deliberately columns-only, then the author-time gate is the alternative — refuse a
grouping.fields[]entry that names no column — but the projection fix is the one that matches what the schema already lets you write.Application impact
In the app that found this, the affected view is the manager's org lens: "show me what is outstanding, by business unit". It shipped green, with a passing metadata-binding test that resolves
business_unitagainst the object schema — which it does resolve, correctly, and which is exactly why the test could not catch this.