fix(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid - #7346

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard
Sep 2, 2026
Merged

fix(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid#7346
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard

Conversation

@os-litant

@os-litantos-litant commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7217

Branch forked from origin/main at e75f4c986. Round-1 numbers were measured on 5eb7a8138; the Round 2 section at the end records the CI fix and what was re-run on the final commit 410823207.

Premise re-verified on the base sha

The card's claim holds, unchanged, at the fork point and on current origin/main:

  • packages/plugin-grid/src/ObjectGrid.tsx:2044-2045for (const gf of grouping.fields) { const fieldName = gf.field; with no guard.
  • packages/plugin-grid/src/useGroupedData.ts:251,257,260,268,278,290 — the SECOND dereference site: const f = fields[depth] and then f.field / f.order / f.collapsed.

origin/main moved to 2956d7af8 while this ran. git log e75f4c986..origin/main -- packages/plugin-grid packages/core packages/types shows PR #7284 and PR #7339 landed there, but both target lines are byte-identical and at the same line numbers on current origin/main, so the fork point was kept and the merge queue rebuilds.

The fix

Both sites now read ONE normalized entry list, usableGroupingFields, exported from useGroupedData.ts. An entry is usable when it is an object carrying a non-empty string field.

That admission rule is deliberately the harvester's — exactly what collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts) contributes to the query projection. Keeping the two sets equal is load-bearing, not tidiness: an entry the grid grouped by but the projection ignored would read undefined on every row and bucket every record into one (empty) group, which is the silent wrong answer objectui#7179 addressed. This is a defensive normalizer, not a lenient alias — no off-spec spelling is taught to mean anything; unusable entries are dropped, never coerced. And one bad entry drops the entry, not the grouping: the usable levels still group.

Routing the memo through collectGroupingFieldRefs itself was measured and rejected — see the table below.

Reachability measurement (the card's "Measure which before fixing")

QuestionAnswerEvidence
Does author-time validation reject a null entry?YESGroupingConfigSchema types fields as an array of $strict objects. safeParse({ fields: [null] }) fails with invalid_type at path fields.0. Declared in @objectstack/spec (view.zod d.ts lines 388-409); reached in this repo as @objectstack/spec/ui.
Does objectui's own ListViewSchema inherit that refusal?YESpackages/types/src/zod/objectql.zod.ts:427 imports the spec fields by reference via specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES), and grouping is NOT in LIST_VIEW_LOCAL_OVERRIDES (objectql.zod.ts:310-325), so the entry shape arrives with it.
Does any zod mirror in this repo re-declare the entry shape?NOgit grep -n grouping packages/types/src/zod/ hits only comments (objectql.zod.ts:382,410). The shape arrives by reference, never restated. The PM's assumption 4 is therefore half right: nothing is re-declared locally, but a refusing validator does exist.
Does the plugin-grid render path run any validator over schema.grouping?NONo safeParse / validateSchema call anywhere in packages/plugin-grid/src/** outside tests. @object-ui/core's validateSchema (packages/core/src/validation/schema-validator.ts:458) runs base + retired-node-type + form + children only; packages/core/src/validation/ contains zero occurrences of grouping — matching PR #7339's finding for this key too. ObjectGrid reads schema.grouping straight off its props.
Can the grouping toolbar popover write a hole?NOGroupingEditor.writeFields (packages/components/src/custom/grouping-editor.tsx:76-158) only ever builds object literals ({ ...g, field: e.target.value }, { field, order, collapsed }) and removes with .filter(), which cannot leave a hole. useGroupReorder.ts reorders rendered GROUP KEYS, never grouping.fields[] entries, so it is not a writer at all.
Is collectGroupingFieldRefs the whole fix?NOA GroupingFieldSchema entry carries order and collapsed besides field. The harvester answers with field NAMES, so routing the memo through it would lose exactly the two properties useGroupedData needs for sort order and default-collapsed state. Hence an entry-level normalizer that keeps the entry object.

This is a defensive guard, not a validation gap — but the crash stays live, because nothing on the render path runs the validator that refuses it. A runtime-composed, generated, or agent-written schema reaches the memo unparsed. Both halves of this measurement are pinned in the new test file rather than left as prose.

Not in this PR (for the PM)

There is nothing to file on the accept set: author-time validation already refuses a null entry, so no published validator's accept set needs to move and Clause-② stays "no". packages/types/src/** is untouched.

The measurement did surface one separate defect, filed as objectui#7347 and deliberately not touched here: a grouping field name with surrounding whitespace is accepted by GroupingFieldSchema (a bare z.ZodString), is trimmed by collectGroupingFieldRefs on its way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured: spec safeParse accepts { field: " business_unit " }; the harvester yields ["business_unit"]; the grid buckets by the raw padded key. It is the same silent-wrong-answer class as objectui#7179, and unlike the null entry it survives validation. The guard in this PR admits such an entry (its field is a non-empty string), leaving today's behaviour exactly as it is on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file.

Test evidence

New pin: packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (6 tests — 4 crash pins, 2 reachability measurements). The 7179 pin's narrowed malformed-block fixture was left exactly as it is.

RED first, on the unmodified tree:

× renders instead of throwing when the only grouping entry is null
× renders instead of throwing when the only grouping entry is undefined
× still groups by the usable entry when a null precedes it
× still groups by the usable entry when a null follows it at a deeper level
AssertionError: expected [Function] to not throw an error but
"TypeError: Cannot read properties of null (reading 'field')" was thrown
Tests 4 failed | 2 passed (6)

The 2 that passed on the unmodified tree are the reachability measurements — they are about the schema, not the guard, and are expected green in both directions.

Ablation, one site at a time

Each leg: mutate; prove the mutation landed on disk by counting the injected and the removed text (an edit tool's exit code proves nothing); run; restore with git checkout HEAD -- naming the file by ABSOLUTE path; prove the restore by blob hash against the HEAD blob plus an empty git diff HEAD. The script carried trap restore EXIT INT TERM throughout.

No build leg applies: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist/ in the path.

AblationMutation proven on diskResultRestore proven
A — remove ONLY the ObjectGrid memo guard (hook guard stays)injected for (const gf of grouping.fields) { x1, removed for (const gf of groupingFields) { x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash b8053e5e91d98da07cd30525417411b1c57c0d93 == HEAD blob, git diff HEAD empty
B — remove ONLY the useGroupedData guard (memo guard stays)injected const fields = config?.fields; x1, removed the useMemo(() => usableGroupingFields(rawFields), …) line x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash 98d98f05bdcf27510ff6d9c9f70229a13630b2f5 == HEAD blob, git diff HEAD empty

Both sites are independently load-bearing: guarding either one alone leaves all four crash pins red.

Verification table (round 1, on 5eb7a8138)

Every heavy run went through the shared verification lock (os-verify-lock.sh, slot dev-7217); the verdict quoted is the line the gate or the lock printed, never a bare $?.

WhatCommandVerdict line
New pinpnpm exec vitest run packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsxTest Files 1 passed (1) / Tests 6 passed (6) · VERDICT command-exit 0
plugin-grid suitepnpm exec vitest run packages/plugin-grid/Test Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
plugin-list suite (downstream grouping composer)pnpm exec vitest run packages/plugin-list/Test Files 62 passed (62) / Tests 787 passed (787) · VERDICT command-exit 0
Dependency closure buildpnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' buildVERDICT command-exit 0
Type-checkpnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
Lint, whole package (the CI-identical eslint .)pnpm exec eslint . --format json in packages/plugin-grid148 files, E=0, W=777 · VERDICT command-exit 0
Changeset presencenode scripts/check-changeset-presence.mjs✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7217-grouping-null-entry-guard.md.
Changeset no-majornode scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.
Control bytesnode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).
Phantom depsnode scripts/check-phantom-dependencies.mjsexit 0 — 40 released packages, 3736 source files, 18810 specifiers checked
Package self-importnode scripts/check-package-self-import.mjs✅ No package names itself inside its own src/.
vi.mock specifiersnode scripts/check-vi-mock-specifiers.mjs✅ check-vi-mock-specifiers: OK
vi.mock inheritnode scripts/check-vi-mock-inherit.mjs✅ check-vi-mock-inherit: OK
Governed surfacenode scripts/pm/check-governed-merges.mjs --test … (final file list)✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. (0 of 4 paths hit the register)

Type-check actually covers the new test file

tsc --noEmit alone would have said nothing about it. tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1, so the new test file is genuinely inside the type-check program rather than excluded and silently unmeasured.

Lint delta, against the fork-point blobs

Baseline taken by checking the two modified files out at e75f4c986 into the same paths (so eslint's config resolution is identical), proven landed on disk before measuring (usableGroupingFields occurrences = 0), then restored and proven by blob hash:

FileBase (e75f4c986)FinalDelta
ObjectGrid.tsxE=0 W=213E=0 W=2130 / 0
useGroupedData.tsE=0 W=26E=0 W=260 / 0
groupingNullEntry-7217.test.tsxnew fileE=0 W=1+1 warning

The one added warning is @typescript-eslint/no-explicit-any on const schema: any = { — the same idiom every sibling grid test uses (groupedBooleanLabel.test.tsx, groupingProjection-7179.test.tsx), and deliberate here: the fixture is intentionally off-spec, so a typed shape would refuse the null entry the test exists to feed in.

Round 2 — the CI gate this missed, and the fix

Test (shard 4/4) went red on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts (objectui#6273, ruling objectui#6172). Verbatim:

GroupingFieldEntry — a NEW colliding name
packages/components/src/custom/grouping-editor.tsx:14 — interface declaration
packages/plugin-grid/src/useGroupedData.ts:213 — type declaration

Round 1's alias published GroupingFieldEntry, a name @object-ui/components already owns.

The two are genuinely different shapes, so this took the gate's rename branch rather than re-pointing one at the other:

@object-ui/componentsGroupingFieldEntrythis alias
What it isthe grouping EDITOR's fully-populated valuethe AUTHORED spec entry (z.input of GroupingConfig)
orderrequired'asc' | 'desc'optional (schema default)
collapsedrequiredbooleanoptional (schema default)

Collapsing them would have made one of the two lie, so each keeps its own name. Renamed to UsableGroupingField — verified free across packages, apps and scripts before choosing — which also says what it is: an entry that passed usableGroupingFields. The components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. A comment at the declaration records why the two names differ, so the next reader does not "unify" them. KNOWN_COLLISIONS was NOT touched — it is shrink-only.

Type-level only, no behaviour change.

Reproduced RED locally first, then green, on the final commit 410823207:

WhatVerdict line
The failing gate, before the renameTests 1 failed | 10 passed (11) · VERDICT command-exit 1 — same GroupingFieldEntry — a NEW colliding name message as CI
The gate + the pin, after the renameTest Files 2 passed (2) / Tests 17 passed (17) · VERDICT command-exit 0
The whole scripts/ meta-gate familyTest Files 97 passed (97) / Tests 2738 passed (2738) · VERDICT command-exit 0
plugin-grid suiteTest Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
Closure build + type-checkVERDICT command-exit 0
Lint, useGroupedData.tsE=0 W=26 — unchanged from base · VERDICT command-exit 0
Control bytes✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).

Round 1's local gate derivation was package-scoped — the touched package's suites, type-check and eslint ., plus the check:* scripts whose subject matter the diff touched. It never asked whether a repo-wide gate in scripts/__tests__/ reads the diff, and this one does: it scans every exported type name in the monorepo, so adding one exported type anywhere can redden it. Round 2 therefore ran all 97 of those files rather than only the one that failed, which is the derivation round 1 should have made.

Hot-file constraint (PR #7284)

Honoured. git diff -U0 on ObjectGrid.tsx reports exactly three hunk headers: @@ -43 +43 @@, @@ -2037,2 +2037,9 @@ and @@ -2044 +2051 @@ — the import line 43 plus the grouping memo region. Line 39 (the @object-ui/core import) and the fieldsToShow default-column synthesis at roughly 2788-2810 are untouched — no new @object-ui/core export was needed, because usableGroupingFields lives in plugin-grid's own useGroupedData.ts and rides the existing line-43 ./useGroupedData import. Round 2 touched only useGroupedData.ts.

packages/core/src/utils/grouping-fields.ts was READ as the route candidate and deliberately not edited: the harvester is already correct, and its name-only return is why it cannot be the whole fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

…es the grid
`ObjectGrid`'s `groupValueFormatter` memo walked `schema.grouping.fields` and
read `gf.field` off every entry with no guard, so a single `null` hole threw
`TypeError: Cannot read properties of null (reading 'field')` out of render —
the whole grid, gone, before any projection was built. `useGroupedData` is a
second dereference site of the same list (`fields[depth]` then `.field` /
`.order` / `.collapsed`), so guarding the memo alone would only have moved the
crash one call downstream.
Both sites now read one normalized entry list, `usableGroupingFields`. Its
admission rule is deliberately the harvester's: an entry is usable when it is
an object carrying a non-empty string `field` — exactly what
`collectGroupingFieldRefs` contributes to the query projection. Keeping the two
sets equal is load-bearing; an entry the grid grouped by but the projection
ignored would read `undefined` on every row and bucket every record into one
`(empty)` group, which is the silent wrong answer objectui#7179 closed. One bad
entry is dropped, not the grouping: the usable levels still group.
Measured before fixing, as the card asked: author-time validation ALREADY
refuses a null entry (`GroupingConfigSchema` types `fields` as an array of
`$strict` objects; objectui's `ListViewSchema` inherits it by reference), so
this is a defensive guard rather than a validation gap. The crash stays live
because nothing on the render path runs that validator — `ObjectGrid` reads
`schema.grouping` straight off its props and `validateSchema` is structural.
Both halves of that measurement are pinned in the new test file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-CukaXEZ6.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.50KB117.36KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 5eb7a8138, root-caused, fix in flight: Test (shard 4/4) fails only in scripts/__tests__/one-authority-per-exported-name-6273.test.ts — the new export type GroupingFieldEntry in packages/plugin-grid/src/useGroupedData.ts:213 collides with the GroupingFieldEntry interface packages/components/src/custom/grouping-editor.tsx:14 already declares (one authority per exported name, objectui#6273). Every other check on the head is green. The dev is resumed on the same branch to un-export or rename the alias and re-run the gate; no KNOWN_COLLISIONS line will be added.


Generated by Claude Code

…ty per name
`one-authority-per-exported-name-6273` went red: the alias introduced by the
null-entry guard published `GroupingFieldEntry`, a name `@object-ui/components`
already declares at `custom/grouping-editor.tsx:14`.
The two are genuinely different shapes, so the remedy is the gate's rename
branch rather than re-pointing one at the other: the components interface is
the grouping EDITOR's fully-populated value, with `order` and `collapsed`
REQUIRED, while this one is `z.input` of the spec's `GroupingConfig`, where
both carry defaults and are therefore optional. Collapsing them would make one
of the two lie.
Renamed to `UsableGroupingField` — free repo-wide, and it says what it is: an
entry that passed `usableGroupingFields`. The components interface is
untouched; it keeps the name it already owned. A comment at the declaration
records why the two names differ, so the next reader does not "unify" them.
No behaviour change — the rename is type-level only, and the grid pin is
unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-BshEx1BK.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.59KB117.40KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litant
os-litant marked this pull request as ready for review September 2, 2026 13:17
@os-litant
os-litant added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit e21308eSep 2, 2026
32 checks passed
@os-litant
os-litant deleted the claude/issue-7217-grouping-null-entry-guard branch September 2, 2026 13:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A null entry in grouping.fields[] throws a TypeError that takes down the whole grid, before any projection is built

2 participants

@os-litant@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(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid - #7346

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard
Sep 2, 2026
Merged

fix(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid#7346
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard

Conversation

@os-litant

@os-litantos-litant commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7217

Branch forked from origin/main at e75f4c986. Round-1 numbers were measured on 5eb7a8138; the Round 2 section at the end records the CI fix and what was re-run on the final commit 410823207.

Premise re-verified on the base sha

The card's claim holds, unchanged, at the fork point and on current origin/main:

  • packages/plugin-grid/src/ObjectGrid.tsx:2044-2045for (const gf of grouping.fields) { const fieldName = gf.field; with no guard.
  • packages/plugin-grid/src/useGroupedData.ts:251,257,260,268,278,290 — the SECOND dereference site: const f = fields[depth] and then f.field / f.order / f.collapsed.

origin/main moved to 2956d7af8 while this ran. git log e75f4c986..origin/main -- packages/plugin-grid packages/core packages/types shows PR #7284 and PR #7339 landed there, but both target lines are byte-identical and at the same line numbers on current origin/main, so the fork point was kept and the merge queue rebuilds.

The fix

Both sites now read ONE normalized entry list, usableGroupingFields, exported from useGroupedData.ts. An entry is usable when it is an object carrying a non-empty string field.

That admission rule is deliberately the harvester's — exactly what collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts) contributes to the query projection. Keeping the two sets equal is load-bearing, not tidiness: an entry the grid grouped by but the projection ignored would read undefined on every row and bucket every record into one (empty) group, which is the silent wrong answer objectui#7179 addressed. This is a defensive normalizer, not a lenient alias — no off-spec spelling is taught to mean anything; unusable entries are dropped, never coerced. And one bad entry drops the entry, not the grouping: the usable levels still group.

Routing the memo through collectGroupingFieldRefs itself was measured and rejected — see the table below.

Reachability measurement (the card's "Measure which before fixing")

QuestionAnswerEvidence
Does author-time validation reject a null entry?YESGroupingConfigSchema types fields as an array of $strict objects. safeParse({ fields: [null] }) fails with invalid_type at path fields.0. Declared in @objectstack/spec (view.zod d.ts lines 388-409); reached in this repo as @objectstack/spec/ui.
Does objectui's own ListViewSchema inherit that refusal?YESpackages/types/src/zod/objectql.zod.ts:427 imports the spec fields by reference via specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES), and grouping is NOT in LIST_VIEW_LOCAL_OVERRIDES (objectql.zod.ts:310-325), so the entry shape arrives with it.
Does any zod mirror in this repo re-declare the entry shape?NOgit grep -n grouping packages/types/src/zod/ hits only comments (objectql.zod.ts:382,410). The shape arrives by reference, never restated. The PM's assumption 4 is therefore half right: nothing is re-declared locally, but a refusing validator does exist.
Does the plugin-grid render path run any validator over schema.grouping?NONo safeParse / validateSchema call anywhere in packages/plugin-grid/src/** outside tests. @object-ui/core's validateSchema (packages/core/src/validation/schema-validator.ts:458) runs base + retired-node-type + form + children only; packages/core/src/validation/ contains zero occurrences of grouping — matching PR #7339's finding for this key too. ObjectGrid reads schema.grouping straight off its props.
Can the grouping toolbar popover write a hole?NOGroupingEditor.writeFields (packages/components/src/custom/grouping-editor.tsx:76-158) only ever builds object literals ({ ...g, field: e.target.value }, { field, order, collapsed }) and removes with .filter(), which cannot leave a hole. useGroupReorder.ts reorders rendered GROUP KEYS, never grouping.fields[] entries, so it is not a writer at all.
Is collectGroupingFieldRefs the whole fix?NOA GroupingFieldSchema entry carries order and collapsed besides field. The harvester answers with field NAMES, so routing the memo through it would lose exactly the two properties useGroupedData needs for sort order and default-collapsed state. Hence an entry-level normalizer that keeps the entry object.

This is a defensive guard, not a validation gap — but the crash stays live, because nothing on the render path runs the validator that refuses it. A runtime-composed, generated, or agent-written schema reaches the memo unparsed. Both halves of this measurement are pinned in the new test file rather than left as prose.

Not in this PR (for the PM)

There is nothing to file on the accept set: author-time validation already refuses a null entry, so no published validator's accept set needs to move and Clause-② stays "no". packages/types/src/** is untouched.

The measurement did surface one separate defect, filed as objectui#7347 and deliberately not touched here: a grouping field name with surrounding whitespace is accepted by GroupingFieldSchema (a bare z.ZodString), is trimmed by collectGroupingFieldRefs on its way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured: spec safeParse accepts { field: " business_unit " }; the harvester yields ["business_unit"]; the grid buckets by the raw padded key. It is the same silent-wrong-answer class as objectui#7179, and unlike the null entry it survives validation. The guard in this PR admits such an entry (its field is a non-empty string), leaving today's behaviour exactly as it is on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file.

Test evidence

New pin: packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (6 tests — 4 crash pins, 2 reachability measurements). The 7179 pin's narrowed malformed-block fixture was left exactly as it is.

RED first, on the unmodified tree:

× renders instead of throwing when the only grouping entry is null
× renders instead of throwing when the only grouping entry is undefined
× still groups by the usable entry when a null precedes it
× still groups by the usable entry when a null follows it at a deeper level
AssertionError: expected [Function] to not throw an error but
"TypeError: Cannot read properties of null (reading 'field')" was thrown
Tests 4 failed | 2 passed (6)

The 2 that passed on the unmodified tree are the reachability measurements — they are about the schema, not the guard, and are expected green in both directions.

Ablation, one site at a time

Each leg: mutate; prove the mutation landed on disk by counting the injected and the removed text (an edit tool's exit code proves nothing); run; restore with git checkout HEAD -- naming the file by ABSOLUTE path; prove the restore by blob hash against the HEAD blob plus an empty git diff HEAD. The script carried trap restore EXIT INT TERM throughout.

No build leg applies: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist/ in the path.

AblationMutation proven on diskResultRestore proven
A — remove ONLY the ObjectGrid memo guard (hook guard stays)injected for (const gf of grouping.fields) { x1, removed for (const gf of groupingFields) { x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash b8053e5e91d98da07cd30525417411b1c57c0d93 == HEAD blob, git diff HEAD empty
B — remove ONLY the useGroupedData guard (memo guard stays)injected const fields = config?.fields; x1, removed the useMemo(() => usableGroupingFields(rawFields), …) line x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash 98d98f05bdcf27510ff6d9c9f70229a13630b2f5 == HEAD blob, git diff HEAD empty

Both sites are independently load-bearing: guarding either one alone leaves all four crash pins red.

Verification table (round 1, on 5eb7a8138)

Every heavy run went through the shared verification lock (os-verify-lock.sh, slot dev-7217); the verdict quoted is the line the gate or the lock printed, never a bare $?.

WhatCommandVerdict line
New pinpnpm exec vitest run packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsxTest Files 1 passed (1) / Tests 6 passed (6) · VERDICT command-exit 0
plugin-grid suitepnpm exec vitest run packages/plugin-grid/Test Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
plugin-list suite (downstream grouping composer)pnpm exec vitest run packages/plugin-list/Test Files 62 passed (62) / Tests 787 passed (787) · VERDICT command-exit 0
Dependency closure buildpnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' buildVERDICT command-exit 0
Type-checkpnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
Lint, whole package (the CI-identical eslint .)pnpm exec eslint . --format json in packages/plugin-grid148 files, E=0, W=777 · VERDICT command-exit 0
Changeset presencenode scripts/check-changeset-presence.mjs✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7217-grouping-null-entry-guard.md.
Changeset no-majornode scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.
Control bytesnode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).
Phantom depsnode scripts/check-phantom-dependencies.mjsexit 0 — 40 released packages, 3736 source files, 18810 specifiers checked
Package self-importnode scripts/check-package-self-import.mjs✅ No package names itself inside its own src/.
vi.mock specifiersnode scripts/check-vi-mock-specifiers.mjs✅ check-vi-mock-specifiers: OK
vi.mock inheritnode scripts/check-vi-mock-inherit.mjs✅ check-vi-mock-inherit: OK
Governed surfacenode scripts/pm/check-governed-merges.mjs --test … (final file list)✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. (0 of 4 paths hit the register)

Type-check actually covers the new test file

tsc --noEmit alone would have said nothing about it. tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1, so the new test file is genuinely inside the type-check program rather than excluded and silently unmeasured.

Lint delta, against the fork-point blobs

Baseline taken by checking the two modified files out at e75f4c986 into the same paths (so eslint's config resolution is identical), proven landed on disk before measuring (usableGroupingFields occurrences = 0), then restored and proven by blob hash:

FileBase (e75f4c986)FinalDelta
ObjectGrid.tsxE=0 W=213E=0 W=2130 / 0
useGroupedData.tsE=0 W=26E=0 W=260 / 0
groupingNullEntry-7217.test.tsxnew fileE=0 W=1+1 warning

The one added warning is @typescript-eslint/no-explicit-any on const schema: any = { — the same idiom every sibling grid test uses (groupedBooleanLabel.test.tsx, groupingProjection-7179.test.tsx), and deliberate here: the fixture is intentionally off-spec, so a typed shape would refuse the null entry the test exists to feed in.

Round 2 — the CI gate this missed, and the fix

Test (shard 4/4) went red on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts (objectui#6273, ruling objectui#6172). Verbatim:

GroupingFieldEntry — a NEW colliding name
packages/components/src/custom/grouping-editor.tsx:14 — interface declaration
packages/plugin-grid/src/useGroupedData.ts:213 — type declaration

Round 1's alias published GroupingFieldEntry, a name @object-ui/components already owns.

The two are genuinely different shapes, so this took the gate's rename branch rather than re-pointing one at the other:

@object-ui/componentsGroupingFieldEntrythis alias
What it isthe grouping EDITOR's fully-populated valuethe AUTHORED spec entry (z.input of GroupingConfig)
orderrequired'asc' | 'desc'optional (schema default)
collapsedrequiredbooleanoptional (schema default)

Collapsing them would have made one of the two lie, so each keeps its own name. Renamed to UsableGroupingField — verified free across packages, apps and scripts before choosing — which also says what it is: an entry that passed usableGroupingFields. The components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. A comment at the declaration records why the two names differ, so the next reader does not "unify" them. KNOWN_COLLISIONS was NOT touched — it is shrink-only.

Type-level only, no behaviour change.

Reproduced RED locally first, then green, on the final commit 410823207:

WhatVerdict line
The failing gate, before the renameTests 1 failed | 10 passed (11) · VERDICT command-exit 1 — same GroupingFieldEntry — a NEW colliding name message as CI
The gate + the pin, after the renameTest Files 2 passed (2) / Tests 17 passed (17) · VERDICT command-exit 0
The whole scripts/ meta-gate familyTest Files 97 passed (97) / Tests 2738 passed (2738) · VERDICT command-exit 0
plugin-grid suiteTest Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
Closure build + type-checkVERDICT command-exit 0
Lint, useGroupedData.tsE=0 W=26 — unchanged from base · VERDICT command-exit 0
Control bytes✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).

Round 1's local gate derivation was package-scoped — the touched package's suites, type-check and eslint ., plus the check:* scripts whose subject matter the diff touched. It never asked whether a repo-wide gate in scripts/__tests__/ reads the diff, and this one does: it scans every exported type name in the monorepo, so adding one exported type anywhere can redden it. Round 2 therefore ran all 97 of those files rather than only the one that failed, which is the derivation round 1 should have made.

Hot-file constraint (PR #7284)

Honoured. git diff -U0 on ObjectGrid.tsx reports exactly three hunk headers: @@ -43 +43 @@, @@ -2037,2 +2037,9 @@ and @@ -2044 +2051 @@ — the import line 43 plus the grouping memo region. Line 39 (the @object-ui/core import) and the fieldsToShow default-column synthesis at roughly 2788-2810 are untouched — no new @object-ui/core export was needed, because usableGroupingFields lives in plugin-grid's own useGroupedData.ts and rides the existing line-43 ./useGroupedData import. Round 2 touched only useGroupedData.ts.

packages/core/src/utils/grouping-fields.ts was READ as the route candidate and deliberately not edited: the harvester is already correct, and its name-only return is why it cannot be the whole fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

…es the grid
`ObjectGrid`'s `groupValueFormatter` memo walked `schema.grouping.fields` and
read `gf.field` off every entry with no guard, so a single `null` hole threw
`TypeError: Cannot read properties of null (reading 'field')` out of render —
the whole grid, gone, before any projection was built. `useGroupedData` is a
second dereference site of the same list (`fields[depth]` then `.field` /
`.order` / `.collapsed`), so guarding the memo alone would only have moved the
crash one call downstream.
Both sites now read one normalized entry list, `usableGroupingFields`. Its
admission rule is deliberately the harvester's: an entry is usable when it is
an object carrying a non-empty string `field` — exactly what
`collectGroupingFieldRefs` contributes to the query projection. Keeping the two
sets equal is load-bearing; an entry the grid grouped by but the projection
ignored would read `undefined` on every row and bucket every record into one
`(empty)` group, which is the silent wrong answer objectui#7179 closed. One bad
entry is dropped, not the grouping: the usable levels still group.
Measured before fixing, as the card asked: author-time validation ALREADY
refuses a null entry (`GroupingConfigSchema` types `fields` as an array of
`$strict` objects; objectui's `ListViewSchema` inherits it by reference), so
this is a defensive guard rather than a validation gap. The crash stays live
because nothing on the render path runs that validator — `ObjectGrid` reads
`schema.grouping` straight off its props and `validateSchema` is structural.
Both halves of that measurement are pinned in the new test file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-CukaXEZ6.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.50KB117.36KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 5eb7a8138, root-caused, fix in flight: Test (shard 4/4) fails only in scripts/__tests__/one-authority-per-exported-name-6273.test.ts — the new export type GroupingFieldEntry in packages/plugin-grid/src/useGroupedData.ts:213 collides with the GroupingFieldEntry interface packages/components/src/custom/grouping-editor.tsx:14 already declares (one authority per exported name, objectui#6273). Every other check on the head is green. The dev is resumed on the same branch to un-export or rename the alias and re-run the gate; no KNOWN_COLLISIONS line will be added.


Generated by Claude Code

…ty per name
`one-authority-per-exported-name-6273` went red: the alias introduced by the
null-entry guard published `GroupingFieldEntry`, a name `@object-ui/components`
already declares at `custom/grouping-editor.tsx:14`.
The two are genuinely different shapes, so the remedy is the gate's rename
branch rather than re-pointing one at the other: the components interface is
the grouping EDITOR's fully-populated value, with `order` and `collapsed`
REQUIRED, while this one is `z.input` of the spec's `GroupingConfig`, where
both carry defaults and are therefore optional. Collapsing them would make one
of the two lie.
Renamed to `UsableGroupingField` — free repo-wide, and it says what it is: an
entry that passed `usableGroupingFields`. The components interface is
untouched; it keeps the name it already owned. A comment at the declaration
records why the two names differ, so the next reader does not "unify" them.
No behaviour change — the rename is type-level only, and the grid pin is
unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-BshEx1BK.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.59KB117.40KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litant
os-litant marked this pull request as ready for review September 2, 2026 13:17
@os-litant
os-litant added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit e21308eSep 2, 2026
32 checks passed
@os-litant
os-litant deleted the claude/issue-7217-grouping-null-entry-guard branch September 2, 2026 13:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A null entry in grouping.fields[] throws a TypeError that takes down the whole grid, before any projection is built

2 participants

@os-litant@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(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid - #7346

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard
Sep 2, 2026
Merged

fix(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid#7346
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard

Conversation

@os-litant

@os-litantos-litant commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7217

Branch forked from origin/main at e75f4c986. Round-1 numbers were measured on 5eb7a8138; the Round 2 section at the end records the CI fix and what was re-run on the final commit 410823207.

Premise re-verified on the base sha

The card's claim holds, unchanged, at the fork point and on current origin/main:

  • packages/plugin-grid/src/ObjectGrid.tsx:2044-2045for (const gf of grouping.fields) { const fieldName = gf.field; with no guard.
  • packages/plugin-grid/src/useGroupedData.ts:251,257,260,268,278,290 — the SECOND dereference site: const f = fields[depth] and then f.field / f.order / f.collapsed.

origin/main moved to 2956d7af8 while this ran. git log e75f4c986..origin/main -- packages/plugin-grid packages/core packages/types shows PR #7284 and PR #7339 landed there, but both target lines are byte-identical and at the same line numbers on current origin/main, so the fork point was kept and the merge queue rebuilds.

The fix

Both sites now read ONE normalized entry list, usableGroupingFields, exported from useGroupedData.ts. An entry is usable when it is an object carrying a non-empty string field.

That admission rule is deliberately the harvester's — exactly what collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts) contributes to the query projection. Keeping the two sets equal is load-bearing, not tidiness: an entry the grid grouped by but the projection ignored would read undefined on every row and bucket every record into one (empty) group, which is the silent wrong answer objectui#7179 addressed. This is a defensive normalizer, not a lenient alias — no off-spec spelling is taught to mean anything; unusable entries are dropped, never coerced. And one bad entry drops the entry, not the grouping: the usable levels still group.

Routing the memo through collectGroupingFieldRefs itself was measured and rejected — see the table below.

Reachability measurement (the card's "Measure which before fixing")

QuestionAnswerEvidence
Does author-time validation reject a null entry?YESGroupingConfigSchema types fields as an array of $strict objects. safeParse({ fields: [null] }) fails with invalid_type at path fields.0. Declared in @objectstack/spec (view.zod d.ts lines 388-409); reached in this repo as @objectstack/spec/ui.
Does objectui's own ListViewSchema inherit that refusal?YESpackages/types/src/zod/objectql.zod.ts:427 imports the spec fields by reference via specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES), and grouping is NOT in LIST_VIEW_LOCAL_OVERRIDES (objectql.zod.ts:310-325), so the entry shape arrives with it.
Does any zod mirror in this repo re-declare the entry shape?NOgit grep -n grouping packages/types/src/zod/ hits only comments (objectql.zod.ts:382,410). The shape arrives by reference, never restated. The PM's assumption 4 is therefore half right: nothing is re-declared locally, but a refusing validator does exist.
Does the plugin-grid render path run any validator over schema.grouping?NONo safeParse / validateSchema call anywhere in packages/plugin-grid/src/** outside tests. @object-ui/core's validateSchema (packages/core/src/validation/schema-validator.ts:458) runs base + retired-node-type + form + children only; packages/core/src/validation/ contains zero occurrences of grouping — matching PR #7339's finding for this key too. ObjectGrid reads schema.grouping straight off its props.
Can the grouping toolbar popover write a hole?NOGroupingEditor.writeFields (packages/components/src/custom/grouping-editor.tsx:76-158) only ever builds object literals ({ ...g, field: e.target.value }, { field, order, collapsed }) and removes with .filter(), which cannot leave a hole. useGroupReorder.ts reorders rendered GROUP KEYS, never grouping.fields[] entries, so it is not a writer at all.
Is collectGroupingFieldRefs the whole fix?NOA GroupingFieldSchema entry carries order and collapsed besides field. The harvester answers with field NAMES, so routing the memo through it would lose exactly the two properties useGroupedData needs for sort order and default-collapsed state. Hence an entry-level normalizer that keeps the entry object.

This is a defensive guard, not a validation gap — but the crash stays live, because nothing on the render path runs the validator that refuses it. A runtime-composed, generated, or agent-written schema reaches the memo unparsed. Both halves of this measurement are pinned in the new test file rather than left as prose.

Not in this PR (for the PM)

There is nothing to file on the accept set: author-time validation already refuses a null entry, so no published validator's accept set needs to move and Clause-② stays "no". packages/types/src/** is untouched.

The measurement did surface one separate defect, filed as objectui#7347 and deliberately not touched here: a grouping field name with surrounding whitespace is accepted by GroupingFieldSchema (a bare z.ZodString), is trimmed by collectGroupingFieldRefs on its way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured: spec safeParse accepts { field: " business_unit " }; the harvester yields ["business_unit"]; the grid buckets by the raw padded key. It is the same silent-wrong-answer class as objectui#7179, and unlike the null entry it survives validation. The guard in this PR admits such an entry (its field is a non-empty string), leaving today's behaviour exactly as it is on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file.

Test evidence

New pin: packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (6 tests — 4 crash pins, 2 reachability measurements). The 7179 pin's narrowed malformed-block fixture was left exactly as it is.

RED first, on the unmodified tree:

× renders instead of throwing when the only grouping entry is null
× renders instead of throwing when the only grouping entry is undefined
× still groups by the usable entry when a null precedes it
× still groups by the usable entry when a null follows it at a deeper level
AssertionError: expected [Function] to not throw an error but
"TypeError: Cannot read properties of null (reading 'field')" was thrown
Tests 4 failed | 2 passed (6)

The 2 that passed on the unmodified tree are the reachability measurements — they are about the schema, not the guard, and are expected green in both directions.

Ablation, one site at a time

Each leg: mutate; prove the mutation landed on disk by counting the injected and the removed text (an edit tool's exit code proves nothing); run; restore with git checkout HEAD -- naming the file by ABSOLUTE path; prove the restore by blob hash against the HEAD blob plus an empty git diff HEAD. The script carried trap restore EXIT INT TERM throughout.

No build leg applies: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist/ in the path.

AblationMutation proven on diskResultRestore proven
A — remove ONLY the ObjectGrid memo guard (hook guard stays)injected for (const gf of grouping.fields) { x1, removed for (const gf of groupingFields) { x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash b8053e5e91d98da07cd30525417411b1c57c0d93 == HEAD blob, git diff HEAD empty
B — remove ONLY the useGroupedData guard (memo guard stays)injected const fields = config?.fields; x1, removed the useMemo(() => usableGroupingFields(rawFields), …) line x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash 98d98f05bdcf27510ff6d9c9f70229a13630b2f5 == HEAD blob, git diff HEAD empty

Both sites are independently load-bearing: guarding either one alone leaves all four crash pins red.

Verification table (round 1, on 5eb7a8138)

Every heavy run went through the shared verification lock (os-verify-lock.sh, slot dev-7217); the verdict quoted is the line the gate or the lock printed, never a bare $?.

WhatCommandVerdict line
New pinpnpm exec vitest run packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsxTest Files 1 passed (1) / Tests 6 passed (6) · VERDICT command-exit 0
plugin-grid suitepnpm exec vitest run packages/plugin-grid/Test Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
plugin-list suite (downstream grouping composer)pnpm exec vitest run packages/plugin-list/Test Files 62 passed (62) / Tests 787 passed (787) · VERDICT command-exit 0
Dependency closure buildpnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' buildVERDICT command-exit 0
Type-checkpnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
Lint, whole package (the CI-identical eslint .)pnpm exec eslint . --format json in packages/plugin-grid148 files, E=0, W=777 · VERDICT command-exit 0
Changeset presencenode scripts/check-changeset-presence.mjs✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7217-grouping-null-entry-guard.md.
Changeset no-majornode scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.
Control bytesnode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).
Phantom depsnode scripts/check-phantom-dependencies.mjsexit 0 — 40 released packages, 3736 source files, 18810 specifiers checked
Package self-importnode scripts/check-package-self-import.mjs✅ No package names itself inside its own src/.
vi.mock specifiersnode scripts/check-vi-mock-specifiers.mjs✅ check-vi-mock-specifiers: OK
vi.mock inheritnode scripts/check-vi-mock-inherit.mjs✅ check-vi-mock-inherit: OK
Governed surfacenode scripts/pm/check-governed-merges.mjs --test … (final file list)✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. (0 of 4 paths hit the register)

Type-check actually covers the new test file

tsc --noEmit alone would have said nothing about it. tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1, so the new test file is genuinely inside the type-check program rather than excluded and silently unmeasured.

Lint delta, against the fork-point blobs

Baseline taken by checking the two modified files out at e75f4c986 into the same paths (so eslint's config resolution is identical), proven landed on disk before measuring (usableGroupingFields occurrences = 0), then restored and proven by blob hash:

FileBase (e75f4c986)FinalDelta
ObjectGrid.tsxE=0 W=213E=0 W=2130 / 0
useGroupedData.tsE=0 W=26E=0 W=260 / 0
groupingNullEntry-7217.test.tsxnew fileE=0 W=1+1 warning

The one added warning is @typescript-eslint/no-explicit-any on const schema: any = { — the same idiom every sibling grid test uses (groupedBooleanLabel.test.tsx, groupingProjection-7179.test.tsx), and deliberate here: the fixture is intentionally off-spec, so a typed shape would refuse the null entry the test exists to feed in.

Round 2 — the CI gate this missed, and the fix

Test (shard 4/4) went red on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts (objectui#6273, ruling objectui#6172). Verbatim:

GroupingFieldEntry — a NEW colliding name
packages/components/src/custom/grouping-editor.tsx:14 — interface declaration
packages/plugin-grid/src/useGroupedData.ts:213 — type declaration

Round 1's alias published GroupingFieldEntry, a name @object-ui/components already owns.

The two are genuinely different shapes, so this took the gate's rename branch rather than re-pointing one at the other:

@object-ui/componentsGroupingFieldEntrythis alias
What it isthe grouping EDITOR's fully-populated valuethe AUTHORED spec entry (z.input of GroupingConfig)
orderrequired'asc' | 'desc'optional (schema default)
collapsedrequiredbooleanoptional (schema default)

Collapsing them would have made one of the two lie, so each keeps its own name. Renamed to UsableGroupingField — verified free across packages, apps and scripts before choosing — which also says what it is: an entry that passed usableGroupingFields. The components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. A comment at the declaration records why the two names differ, so the next reader does not "unify" them. KNOWN_COLLISIONS was NOT touched — it is shrink-only.

Type-level only, no behaviour change.

Reproduced RED locally first, then green, on the final commit 410823207:

WhatVerdict line
The failing gate, before the renameTests 1 failed | 10 passed (11) · VERDICT command-exit 1 — same GroupingFieldEntry — a NEW colliding name message as CI
The gate + the pin, after the renameTest Files 2 passed (2) / Tests 17 passed (17) · VERDICT command-exit 0
The whole scripts/ meta-gate familyTest Files 97 passed (97) / Tests 2738 passed (2738) · VERDICT command-exit 0
plugin-grid suiteTest Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
Closure build + type-checkVERDICT command-exit 0
Lint, useGroupedData.tsE=0 W=26 — unchanged from base · VERDICT command-exit 0
Control bytes✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).

Round 1's local gate derivation was package-scoped — the touched package's suites, type-check and eslint ., plus the check:* scripts whose subject matter the diff touched. It never asked whether a repo-wide gate in scripts/__tests__/ reads the diff, and this one does: it scans every exported type name in the monorepo, so adding one exported type anywhere can redden it. Round 2 therefore ran all 97 of those files rather than only the one that failed, which is the derivation round 1 should have made.

Hot-file constraint (PR #7284)

Honoured. git diff -U0 on ObjectGrid.tsx reports exactly three hunk headers: @@ -43 +43 @@, @@ -2037,2 +2037,9 @@ and @@ -2044 +2051 @@ — the import line 43 plus the grouping memo region. Line 39 (the @object-ui/core import) and the fieldsToShow default-column synthesis at roughly 2788-2810 are untouched — no new @object-ui/core export was needed, because usableGroupingFields lives in plugin-grid's own useGroupedData.ts and rides the existing line-43 ./useGroupedData import. Round 2 touched only useGroupedData.ts.

packages/core/src/utils/grouping-fields.ts was READ as the route candidate and deliberately not edited: the harvester is already correct, and its name-only return is why it cannot be the whole fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

…es the grid
`ObjectGrid`'s `groupValueFormatter` memo walked `schema.grouping.fields` and
read `gf.field` off every entry with no guard, so a single `null` hole threw
`TypeError: Cannot read properties of null (reading 'field')` out of render —
the whole grid, gone, before any projection was built. `useGroupedData` is a
second dereference site of the same list (`fields[depth]` then `.field` /
`.order` / `.collapsed`), so guarding the memo alone would only have moved the
crash one call downstream.
Both sites now read one normalized entry list, `usableGroupingFields`. Its
admission rule is deliberately the harvester's: an entry is usable when it is
an object carrying a non-empty string `field` — exactly what
`collectGroupingFieldRefs` contributes to the query projection. Keeping the two
sets equal is load-bearing; an entry the grid grouped by but the projection
ignored would read `undefined` on every row and bucket every record into one
`(empty)` group, which is the silent wrong answer objectui#7179 closed. One bad
entry is dropped, not the grouping: the usable levels still group.
Measured before fixing, as the card asked: author-time validation ALREADY
refuses a null entry (`GroupingConfigSchema` types `fields` as an array of
`$strict` objects; objectui's `ListViewSchema` inherits it by reference), so
this is a defensive guard rather than a validation gap. The crash stays live
because nothing on the render path runs that validator — `ObjectGrid` reads
`schema.grouping` straight off its props and `validateSchema` is structural.
Both halves of that measurement are pinned in the new test file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-CukaXEZ6.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.50KB117.36KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 5eb7a8138, root-caused, fix in flight: Test (shard 4/4) fails only in scripts/__tests__/one-authority-per-exported-name-6273.test.ts — the new export type GroupingFieldEntry in packages/plugin-grid/src/useGroupedData.ts:213 collides with the GroupingFieldEntry interface packages/components/src/custom/grouping-editor.tsx:14 already declares (one authority per exported name, objectui#6273). Every other check on the head is green. The dev is resumed on the same branch to un-export or rename the alias and re-run the gate; no KNOWN_COLLISIONS line will be added.


Generated by Claude Code

…ty per name
`one-authority-per-exported-name-6273` went red: the alias introduced by the
null-entry guard published `GroupingFieldEntry`, a name `@object-ui/components`
already declares at `custom/grouping-editor.tsx:14`.
The two are genuinely different shapes, so the remedy is the gate's rename
branch rather than re-pointing one at the other: the components interface is
the grouping EDITOR's fully-populated value, with `order` and `collapsed`
REQUIRED, while this one is `z.input` of the spec's `GroupingConfig`, where
both carry defaults and are therefore optional. Collapsing them would make one
of the two lie.
Renamed to `UsableGroupingField` — free repo-wide, and it says what it is: an
entry that passed `usableGroupingFields`. The components interface is
untouched; it keeps the name it already owned. A comment at the declaration
records why the two names differ, so the next reader does not "unify" them.
No behaviour change — the rename is type-level only, and the grid pin is
unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-BshEx1BK.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.59KB117.40KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litant
os-litant marked this pull request as ready for review September 2, 2026 13:17
@os-litant
os-litant added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit e21308eSep 2, 2026
32 checks passed
@os-litant
os-litant deleted the claude/issue-7217-grouping-null-entry-guard branch September 2, 2026 13:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A null entry in grouping.fields[] throws a TypeError that takes down the whole grid, before any projection is built

2 participants

@os-litant@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(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid - #7346

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard
Sep 2, 2026
Merged

fix(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid#7346
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard

Conversation

@os-litant

@os-litantos-litant commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7217

Branch forked from origin/main at e75f4c986. Round-1 numbers were measured on 5eb7a8138; the Round 2 section at the end records the CI fix and what was re-run on the final commit 410823207.

Premise re-verified on the base sha

The card's claim holds, unchanged, at the fork point and on current origin/main:

  • packages/plugin-grid/src/ObjectGrid.tsx:2044-2045for (const gf of grouping.fields) { const fieldName = gf.field; with no guard.
  • packages/plugin-grid/src/useGroupedData.ts:251,257,260,268,278,290 — the SECOND dereference site: const f = fields[depth] and then f.field / f.order / f.collapsed.

origin/main moved to 2956d7af8 while this ran. git log e75f4c986..origin/main -- packages/plugin-grid packages/core packages/types shows PR #7284 and PR #7339 landed there, but both target lines are byte-identical and at the same line numbers on current origin/main, so the fork point was kept and the merge queue rebuilds.

The fix

Both sites now read ONE normalized entry list, usableGroupingFields, exported from useGroupedData.ts. An entry is usable when it is an object carrying a non-empty string field.

That admission rule is deliberately the harvester's — exactly what collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts) contributes to the query projection. Keeping the two sets equal is load-bearing, not tidiness: an entry the grid grouped by but the projection ignored would read undefined on every row and bucket every record into one (empty) group, which is the silent wrong answer objectui#7179 addressed. This is a defensive normalizer, not a lenient alias — no off-spec spelling is taught to mean anything; unusable entries are dropped, never coerced. And one bad entry drops the entry, not the grouping: the usable levels still group.

Routing the memo through collectGroupingFieldRefs itself was measured and rejected — see the table below.

Reachability measurement (the card's "Measure which before fixing")

QuestionAnswerEvidence
Does author-time validation reject a null entry?YESGroupingConfigSchema types fields as an array of $strict objects. safeParse({ fields: [null] }) fails with invalid_type at path fields.0. Declared in @objectstack/spec (view.zod d.ts lines 388-409); reached in this repo as @objectstack/spec/ui.
Does objectui's own ListViewSchema inherit that refusal?YESpackages/types/src/zod/objectql.zod.ts:427 imports the spec fields by reference via specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES), and grouping is NOT in LIST_VIEW_LOCAL_OVERRIDES (objectql.zod.ts:310-325), so the entry shape arrives with it.
Does any zod mirror in this repo re-declare the entry shape?NOgit grep -n grouping packages/types/src/zod/ hits only comments (objectql.zod.ts:382,410). The shape arrives by reference, never restated. The PM's assumption 4 is therefore half right: nothing is re-declared locally, but a refusing validator does exist.
Does the plugin-grid render path run any validator over schema.grouping?NONo safeParse / validateSchema call anywhere in packages/plugin-grid/src/** outside tests. @object-ui/core's validateSchema (packages/core/src/validation/schema-validator.ts:458) runs base + retired-node-type + form + children only; packages/core/src/validation/ contains zero occurrences of grouping — matching PR #7339's finding for this key too. ObjectGrid reads schema.grouping straight off its props.
Can the grouping toolbar popover write a hole?NOGroupingEditor.writeFields (packages/components/src/custom/grouping-editor.tsx:76-158) only ever builds object literals ({ ...g, field: e.target.value }, { field, order, collapsed }) and removes with .filter(), which cannot leave a hole. useGroupReorder.ts reorders rendered GROUP KEYS, never grouping.fields[] entries, so it is not a writer at all.
Is collectGroupingFieldRefs the whole fix?NOA GroupingFieldSchema entry carries order and collapsed besides field. The harvester answers with field NAMES, so routing the memo through it would lose exactly the two properties useGroupedData needs for sort order and default-collapsed state. Hence an entry-level normalizer that keeps the entry object.

This is a defensive guard, not a validation gap — but the crash stays live, because nothing on the render path runs the validator that refuses it. A runtime-composed, generated, or agent-written schema reaches the memo unparsed. Both halves of this measurement are pinned in the new test file rather than left as prose.

Not in this PR (for the PM)

There is nothing to file on the accept set: author-time validation already refuses a null entry, so no published validator's accept set needs to move and Clause-② stays "no". packages/types/src/** is untouched.

The measurement did surface one separate defect, filed as objectui#7347 and deliberately not touched here: a grouping field name with surrounding whitespace is accepted by GroupingFieldSchema (a bare z.ZodString), is trimmed by collectGroupingFieldRefs on its way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured: spec safeParse accepts { field: " business_unit " }; the harvester yields ["business_unit"]; the grid buckets by the raw padded key. It is the same silent-wrong-answer class as objectui#7179, and unlike the null entry it survives validation. The guard in this PR admits such an entry (its field is a non-empty string), leaving today's behaviour exactly as it is on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file.

Test evidence

New pin: packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (6 tests — 4 crash pins, 2 reachability measurements). The 7179 pin's narrowed malformed-block fixture was left exactly as it is.

RED first, on the unmodified tree:

× renders instead of throwing when the only grouping entry is null
× renders instead of throwing when the only grouping entry is undefined
× still groups by the usable entry when a null precedes it
× still groups by the usable entry when a null follows it at a deeper level
AssertionError: expected [Function] to not throw an error but
"TypeError: Cannot read properties of null (reading 'field')" was thrown
Tests 4 failed | 2 passed (6)

The 2 that passed on the unmodified tree are the reachability measurements — they are about the schema, not the guard, and are expected green in both directions.

Ablation, one site at a time

Each leg: mutate; prove the mutation landed on disk by counting the injected and the removed text (an edit tool's exit code proves nothing); run; restore with git checkout HEAD -- naming the file by ABSOLUTE path; prove the restore by blob hash against the HEAD blob plus an empty git diff HEAD. The script carried trap restore EXIT INT TERM throughout.

No build leg applies: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist/ in the path.

AblationMutation proven on diskResultRestore proven
A — remove ONLY the ObjectGrid memo guard (hook guard stays)injected for (const gf of grouping.fields) { x1, removed for (const gf of groupingFields) { x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash b8053e5e91d98da07cd30525417411b1c57c0d93 == HEAD blob, git diff HEAD empty
B — remove ONLY the useGroupedData guard (memo guard stays)injected const fields = config?.fields; x1, removed the useMemo(() => usableGroupingFields(rawFields), …) line x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash 98d98f05bdcf27510ff6d9c9f70229a13630b2f5 == HEAD blob, git diff HEAD empty

Both sites are independently load-bearing: guarding either one alone leaves all four crash pins red.

Verification table (round 1, on 5eb7a8138)

Every heavy run went through the shared verification lock (os-verify-lock.sh, slot dev-7217); the verdict quoted is the line the gate or the lock printed, never a bare $?.

WhatCommandVerdict line
New pinpnpm exec vitest run packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsxTest Files 1 passed (1) / Tests 6 passed (6) · VERDICT command-exit 0
plugin-grid suitepnpm exec vitest run packages/plugin-grid/Test Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
plugin-list suite (downstream grouping composer)pnpm exec vitest run packages/plugin-list/Test Files 62 passed (62) / Tests 787 passed (787) · VERDICT command-exit 0
Dependency closure buildpnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' buildVERDICT command-exit 0
Type-checkpnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
Lint, whole package (the CI-identical eslint .)pnpm exec eslint . --format json in packages/plugin-grid148 files, E=0, W=777 · VERDICT command-exit 0
Changeset presencenode scripts/check-changeset-presence.mjs✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7217-grouping-null-entry-guard.md.
Changeset no-majornode scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.
Control bytesnode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).
Phantom depsnode scripts/check-phantom-dependencies.mjsexit 0 — 40 released packages, 3736 source files, 18810 specifiers checked
Package self-importnode scripts/check-package-self-import.mjs✅ No package names itself inside its own src/.
vi.mock specifiersnode scripts/check-vi-mock-specifiers.mjs✅ check-vi-mock-specifiers: OK
vi.mock inheritnode scripts/check-vi-mock-inherit.mjs✅ check-vi-mock-inherit: OK
Governed surfacenode scripts/pm/check-governed-merges.mjs --test … (final file list)✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. (0 of 4 paths hit the register)

Type-check actually covers the new test file

tsc --noEmit alone would have said nothing about it. tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1, so the new test file is genuinely inside the type-check program rather than excluded and silently unmeasured.

Lint delta, against the fork-point blobs

Baseline taken by checking the two modified files out at e75f4c986 into the same paths (so eslint's config resolution is identical), proven landed on disk before measuring (usableGroupingFields occurrences = 0), then restored and proven by blob hash:

FileBase (e75f4c986)FinalDelta
ObjectGrid.tsxE=0 W=213E=0 W=2130 / 0
useGroupedData.tsE=0 W=26E=0 W=260 / 0
groupingNullEntry-7217.test.tsxnew fileE=0 W=1+1 warning

The one added warning is @typescript-eslint/no-explicit-any on const schema: any = { — the same idiom every sibling grid test uses (groupedBooleanLabel.test.tsx, groupingProjection-7179.test.tsx), and deliberate here: the fixture is intentionally off-spec, so a typed shape would refuse the null entry the test exists to feed in.

Round 2 — the CI gate this missed, and the fix

Test (shard 4/4) went red on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts (objectui#6273, ruling objectui#6172). Verbatim:

GroupingFieldEntry — a NEW colliding name
packages/components/src/custom/grouping-editor.tsx:14 — interface declaration
packages/plugin-grid/src/useGroupedData.ts:213 — type declaration

Round 1's alias published GroupingFieldEntry, a name @object-ui/components already owns.

The two are genuinely different shapes, so this took the gate's rename branch rather than re-pointing one at the other:

@object-ui/componentsGroupingFieldEntrythis alias
What it isthe grouping EDITOR's fully-populated valuethe AUTHORED spec entry (z.input of GroupingConfig)
orderrequired'asc' | 'desc'optional (schema default)
collapsedrequiredbooleanoptional (schema default)

Collapsing them would have made one of the two lie, so each keeps its own name. Renamed to UsableGroupingField — verified free across packages, apps and scripts before choosing — which also says what it is: an entry that passed usableGroupingFields. The components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. A comment at the declaration records why the two names differ, so the next reader does not "unify" them. KNOWN_COLLISIONS was NOT touched — it is shrink-only.

Type-level only, no behaviour change.

Reproduced RED locally first, then green, on the final commit 410823207:

WhatVerdict line
The failing gate, before the renameTests 1 failed | 10 passed (11) · VERDICT command-exit 1 — same GroupingFieldEntry — a NEW colliding name message as CI
The gate + the pin, after the renameTest Files 2 passed (2) / Tests 17 passed (17) · VERDICT command-exit 0
The whole scripts/ meta-gate familyTest Files 97 passed (97) / Tests 2738 passed (2738) · VERDICT command-exit 0
plugin-grid suiteTest Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
Closure build + type-checkVERDICT command-exit 0
Lint, useGroupedData.tsE=0 W=26 — unchanged from base · VERDICT command-exit 0
Control bytes✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).

Round 1's local gate derivation was package-scoped — the touched package's suites, type-check and eslint ., plus the check:* scripts whose subject matter the diff touched. It never asked whether a repo-wide gate in scripts/__tests__/ reads the diff, and this one does: it scans every exported type name in the monorepo, so adding one exported type anywhere can redden it. Round 2 therefore ran all 97 of those files rather than only the one that failed, which is the derivation round 1 should have made.

Hot-file constraint (PR #7284)

Honoured. git diff -U0 on ObjectGrid.tsx reports exactly three hunk headers: @@ -43 +43 @@, @@ -2037,2 +2037,9 @@ and @@ -2044 +2051 @@ — the import line 43 plus the grouping memo region. Line 39 (the @object-ui/core import) and the fieldsToShow default-column synthesis at roughly 2788-2810 are untouched — no new @object-ui/core export was needed, because usableGroupingFields lives in plugin-grid's own useGroupedData.ts and rides the existing line-43 ./useGroupedData import. Round 2 touched only useGroupedData.ts.

packages/core/src/utils/grouping-fields.ts was READ as the route candidate and deliberately not edited: the harvester is already correct, and its name-only return is why it cannot be the whole fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

…es the grid
`ObjectGrid`'s `groupValueFormatter` memo walked `schema.grouping.fields` and
read `gf.field` off every entry with no guard, so a single `null` hole threw
`TypeError: Cannot read properties of null (reading 'field')` out of render —
the whole grid, gone, before any projection was built. `useGroupedData` is a
second dereference site of the same list (`fields[depth]` then `.field` /
`.order` / `.collapsed`), so guarding the memo alone would only have moved the
crash one call downstream.
Both sites now read one normalized entry list, `usableGroupingFields`. Its
admission rule is deliberately the harvester's: an entry is usable when it is
an object carrying a non-empty string `field` — exactly what
`collectGroupingFieldRefs` contributes to the query projection. Keeping the two
sets equal is load-bearing; an entry the grid grouped by but the projection
ignored would read `undefined` on every row and bucket every record into one
`(empty)` group, which is the silent wrong answer objectui#7179 closed. One bad
entry is dropped, not the grouping: the usable levels still group.
Measured before fixing, as the card asked: author-time validation ALREADY
refuses a null entry (`GroupingConfigSchema` types `fields` as an array of
`$strict` objects; objectui's `ListViewSchema` inherits it by reference), so
this is a defensive guard rather than a validation gap. The crash stays live
because nothing on the render path runs that validator — `ObjectGrid` reads
`schema.grouping` straight off its props and `validateSchema` is structural.
Both halves of that measurement are pinned in the new test file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-CukaXEZ6.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.50KB117.36KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 5eb7a8138, root-caused, fix in flight: Test (shard 4/4) fails only in scripts/__tests__/one-authority-per-exported-name-6273.test.ts — the new export type GroupingFieldEntry in packages/plugin-grid/src/useGroupedData.ts:213 collides with the GroupingFieldEntry interface packages/components/src/custom/grouping-editor.tsx:14 already declares (one authority per exported name, objectui#6273). Every other check on the head is green. The dev is resumed on the same branch to un-export or rename the alias and re-run the gate; no KNOWN_COLLISIONS line will be added.


Generated by Claude Code

…ty per name
`one-authority-per-exported-name-6273` went red: the alias introduced by the
null-entry guard published `GroupingFieldEntry`, a name `@object-ui/components`
already declares at `custom/grouping-editor.tsx:14`.
The two are genuinely different shapes, so the remedy is the gate's rename
branch rather than re-pointing one at the other: the components interface is
the grouping EDITOR's fully-populated value, with `order` and `collapsed`
REQUIRED, while this one is `z.input` of the spec's `GroupingConfig`, where
both carry defaults and are therefore optional. Collapsing them would make one
of the two lie.
Renamed to `UsableGroupingField` — free repo-wide, and it says what it is: an
entry that passed `usableGroupingFields`. The components interface is
untouched; it keeps the name it already owned. A comment at the declaration
records why the two names differ, so the next reader does not "unify" them.
No behaviour change — the rename is type-level only, and the grid pin is
unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-BshEx1BK.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.59KB117.40KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litant
os-litant marked this pull request as ready for review September 2, 2026 13:17
@os-litant
os-litant added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit e21308eSep 2, 2026
32 checks passed
@os-litant
os-litant deleted the claude/issue-7217-grouping-null-entry-guard branch September 2, 2026 13:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A null entry in grouping.fields[] throws a TypeError that takes down the whole grid, before any projection is built

2 participants

@os-litant@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(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid - #7346

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard
Sep 2, 2026
Merged

fix(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid#7346
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard

Conversation

@os-litant

@os-litantos-litant commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7217

Branch forked from origin/main at e75f4c986. Round-1 numbers were measured on 5eb7a8138; the Round 2 section at the end records the CI fix and what was re-run on the final commit 410823207.

Premise re-verified on the base sha

The card's claim holds, unchanged, at the fork point and on current origin/main:

  • packages/plugin-grid/src/ObjectGrid.tsx:2044-2045for (const gf of grouping.fields) { const fieldName = gf.field; with no guard.
  • packages/plugin-grid/src/useGroupedData.ts:251,257,260,268,278,290 — the SECOND dereference site: const f = fields[depth] and then f.field / f.order / f.collapsed.

origin/main moved to 2956d7af8 while this ran. git log e75f4c986..origin/main -- packages/plugin-grid packages/core packages/types shows PR #7284 and PR #7339 landed there, but both target lines are byte-identical and at the same line numbers on current origin/main, so the fork point was kept and the merge queue rebuilds.

The fix

Both sites now read ONE normalized entry list, usableGroupingFields, exported from useGroupedData.ts. An entry is usable when it is an object carrying a non-empty string field.

That admission rule is deliberately the harvester's — exactly what collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts) contributes to the query projection. Keeping the two sets equal is load-bearing, not tidiness: an entry the grid grouped by but the projection ignored would read undefined on every row and bucket every record into one (empty) group, which is the silent wrong answer objectui#7179 addressed. This is a defensive normalizer, not a lenient alias — no off-spec spelling is taught to mean anything; unusable entries are dropped, never coerced. And one bad entry drops the entry, not the grouping: the usable levels still group.

Routing the memo through collectGroupingFieldRefs itself was measured and rejected — see the table below.

Reachability measurement (the card's "Measure which before fixing")

QuestionAnswerEvidence
Does author-time validation reject a null entry?YESGroupingConfigSchema types fields as an array of $strict objects. safeParse({ fields: [null] }) fails with invalid_type at path fields.0. Declared in @objectstack/spec (view.zod d.ts lines 388-409); reached in this repo as @objectstack/spec/ui.
Does objectui's own ListViewSchema inherit that refusal?YESpackages/types/src/zod/objectql.zod.ts:427 imports the spec fields by reference via specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES), and grouping is NOT in LIST_VIEW_LOCAL_OVERRIDES (objectql.zod.ts:310-325), so the entry shape arrives with it.
Does any zod mirror in this repo re-declare the entry shape?NOgit grep -n grouping packages/types/src/zod/ hits only comments (objectql.zod.ts:382,410). The shape arrives by reference, never restated. The PM's assumption 4 is therefore half right: nothing is re-declared locally, but a refusing validator does exist.
Does the plugin-grid render path run any validator over schema.grouping?NONo safeParse / validateSchema call anywhere in packages/plugin-grid/src/** outside tests. @object-ui/core's validateSchema (packages/core/src/validation/schema-validator.ts:458) runs base + retired-node-type + form + children only; packages/core/src/validation/ contains zero occurrences of grouping — matching PR #7339's finding for this key too. ObjectGrid reads schema.grouping straight off its props.
Can the grouping toolbar popover write a hole?NOGroupingEditor.writeFields (packages/components/src/custom/grouping-editor.tsx:76-158) only ever builds object literals ({ ...g, field: e.target.value }, { field, order, collapsed }) and removes with .filter(), which cannot leave a hole. useGroupReorder.ts reorders rendered GROUP KEYS, never grouping.fields[] entries, so it is not a writer at all.
Is collectGroupingFieldRefs the whole fix?NOA GroupingFieldSchema entry carries order and collapsed besides field. The harvester answers with field NAMES, so routing the memo through it would lose exactly the two properties useGroupedData needs for sort order and default-collapsed state. Hence an entry-level normalizer that keeps the entry object.

This is a defensive guard, not a validation gap — but the crash stays live, because nothing on the render path runs the validator that refuses it. A runtime-composed, generated, or agent-written schema reaches the memo unparsed. Both halves of this measurement are pinned in the new test file rather than left as prose.

Not in this PR (for the PM)

There is nothing to file on the accept set: author-time validation already refuses a null entry, so no published validator's accept set needs to move and Clause-② stays "no". packages/types/src/** is untouched.

The measurement did surface one separate defect, filed as objectui#7347 and deliberately not touched here: a grouping field name with surrounding whitespace is accepted by GroupingFieldSchema (a bare z.ZodString), is trimmed by collectGroupingFieldRefs on its way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured: spec safeParse accepts { field: " business_unit " }; the harvester yields ["business_unit"]; the grid buckets by the raw padded key. It is the same silent-wrong-answer class as objectui#7179, and unlike the null entry it survives validation. The guard in this PR admits such an entry (its field is a non-empty string), leaving today's behaviour exactly as it is on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file.

Test evidence

New pin: packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (6 tests — 4 crash pins, 2 reachability measurements). The 7179 pin's narrowed malformed-block fixture was left exactly as it is.

RED first, on the unmodified tree:

× renders instead of throwing when the only grouping entry is null
× renders instead of throwing when the only grouping entry is undefined
× still groups by the usable entry when a null precedes it
× still groups by the usable entry when a null follows it at a deeper level
AssertionError: expected [Function] to not throw an error but
"TypeError: Cannot read properties of null (reading 'field')" was thrown
Tests 4 failed | 2 passed (6)

The 2 that passed on the unmodified tree are the reachability measurements — they are about the schema, not the guard, and are expected green in both directions.

Ablation, one site at a time

Each leg: mutate; prove the mutation landed on disk by counting the injected and the removed text (an edit tool's exit code proves nothing); run; restore with git checkout HEAD -- naming the file by ABSOLUTE path; prove the restore by blob hash against the HEAD blob plus an empty git diff HEAD. The script carried trap restore EXIT INT TERM throughout.

No build leg applies: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist/ in the path.

AblationMutation proven on diskResultRestore proven
A — remove ONLY the ObjectGrid memo guard (hook guard stays)injected for (const gf of grouping.fields) { x1, removed for (const gf of groupingFields) { x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash b8053e5e91d98da07cd30525417411b1c57c0d93 == HEAD blob, git diff HEAD empty
B — remove ONLY the useGroupedData guard (memo guard stays)injected const fields = config?.fields; x1, removed the useMemo(() => usableGroupingFields(rawFields), …) line x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash 98d98f05bdcf27510ff6d9c9f70229a13630b2f5 == HEAD blob, git diff HEAD empty

Both sites are independently load-bearing: guarding either one alone leaves all four crash pins red.

Verification table (round 1, on 5eb7a8138)

Every heavy run went through the shared verification lock (os-verify-lock.sh, slot dev-7217); the verdict quoted is the line the gate or the lock printed, never a bare $?.

WhatCommandVerdict line
New pinpnpm exec vitest run packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsxTest Files 1 passed (1) / Tests 6 passed (6) · VERDICT command-exit 0
plugin-grid suitepnpm exec vitest run packages/plugin-grid/Test Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
plugin-list suite (downstream grouping composer)pnpm exec vitest run packages/plugin-list/Test Files 62 passed (62) / Tests 787 passed (787) · VERDICT command-exit 0
Dependency closure buildpnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' buildVERDICT command-exit 0
Type-checkpnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
Lint, whole package (the CI-identical eslint .)pnpm exec eslint . --format json in packages/plugin-grid148 files, E=0, W=777 · VERDICT command-exit 0
Changeset presencenode scripts/check-changeset-presence.mjs✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7217-grouping-null-entry-guard.md.
Changeset no-majornode scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.
Control bytesnode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).
Phantom depsnode scripts/check-phantom-dependencies.mjsexit 0 — 40 released packages, 3736 source files, 18810 specifiers checked
Package self-importnode scripts/check-package-self-import.mjs✅ No package names itself inside its own src/.
vi.mock specifiersnode scripts/check-vi-mock-specifiers.mjs✅ check-vi-mock-specifiers: OK
vi.mock inheritnode scripts/check-vi-mock-inherit.mjs✅ check-vi-mock-inherit: OK
Governed surfacenode scripts/pm/check-governed-merges.mjs --test … (final file list)✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. (0 of 4 paths hit the register)

Type-check actually covers the new test file

tsc --noEmit alone would have said nothing about it. tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1, so the new test file is genuinely inside the type-check program rather than excluded and silently unmeasured.

Lint delta, against the fork-point blobs

Baseline taken by checking the two modified files out at e75f4c986 into the same paths (so eslint's config resolution is identical), proven landed on disk before measuring (usableGroupingFields occurrences = 0), then restored and proven by blob hash:

FileBase (e75f4c986)FinalDelta
ObjectGrid.tsxE=0 W=213E=0 W=2130 / 0
useGroupedData.tsE=0 W=26E=0 W=260 / 0
groupingNullEntry-7217.test.tsxnew fileE=0 W=1+1 warning

The one added warning is @typescript-eslint/no-explicit-any on const schema: any = { — the same idiom every sibling grid test uses (groupedBooleanLabel.test.tsx, groupingProjection-7179.test.tsx), and deliberate here: the fixture is intentionally off-spec, so a typed shape would refuse the null entry the test exists to feed in.

Round 2 — the CI gate this missed, and the fix

Test (shard 4/4) went red on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts (objectui#6273, ruling objectui#6172). Verbatim:

GroupingFieldEntry — a NEW colliding name
packages/components/src/custom/grouping-editor.tsx:14 — interface declaration
packages/plugin-grid/src/useGroupedData.ts:213 — type declaration

Round 1's alias published GroupingFieldEntry, a name @object-ui/components already owns.

The two are genuinely different shapes, so this took the gate's rename branch rather than re-pointing one at the other:

@object-ui/componentsGroupingFieldEntrythis alias
What it isthe grouping EDITOR's fully-populated valuethe AUTHORED spec entry (z.input of GroupingConfig)
orderrequired'asc' | 'desc'optional (schema default)
collapsedrequiredbooleanoptional (schema default)

Collapsing them would have made one of the two lie, so each keeps its own name. Renamed to UsableGroupingField — verified free across packages, apps and scripts before choosing — which also says what it is: an entry that passed usableGroupingFields. The components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. A comment at the declaration records why the two names differ, so the next reader does not "unify" them. KNOWN_COLLISIONS was NOT touched — it is shrink-only.

Type-level only, no behaviour change.

Reproduced RED locally first, then green, on the final commit 410823207:

WhatVerdict line
The failing gate, before the renameTests 1 failed | 10 passed (11) · VERDICT command-exit 1 — same GroupingFieldEntry — a NEW colliding name message as CI
The gate + the pin, after the renameTest Files 2 passed (2) / Tests 17 passed (17) · VERDICT command-exit 0
The whole scripts/ meta-gate familyTest Files 97 passed (97) / Tests 2738 passed (2738) · VERDICT command-exit 0
plugin-grid suiteTest Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
Closure build + type-checkVERDICT command-exit 0
Lint, useGroupedData.tsE=0 W=26 — unchanged from base · VERDICT command-exit 0
Control bytes✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).

Round 1's local gate derivation was package-scoped — the touched package's suites, type-check and eslint ., plus the check:* scripts whose subject matter the diff touched. It never asked whether a repo-wide gate in scripts/__tests__/ reads the diff, and this one does: it scans every exported type name in the monorepo, so adding one exported type anywhere can redden it. Round 2 therefore ran all 97 of those files rather than only the one that failed, which is the derivation round 1 should have made.

Hot-file constraint (PR #7284)

Honoured. git diff -U0 on ObjectGrid.tsx reports exactly three hunk headers: @@ -43 +43 @@, @@ -2037,2 +2037,9 @@ and @@ -2044 +2051 @@ — the import line 43 plus the grouping memo region. Line 39 (the @object-ui/core import) and the fieldsToShow default-column synthesis at roughly 2788-2810 are untouched — no new @object-ui/core export was needed, because usableGroupingFields lives in plugin-grid's own useGroupedData.ts and rides the existing line-43 ./useGroupedData import. Round 2 touched only useGroupedData.ts.

packages/core/src/utils/grouping-fields.ts was READ as the route candidate and deliberately not edited: the harvester is already correct, and its name-only return is why it cannot be the whole fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

…es the grid
`ObjectGrid`'s `groupValueFormatter` memo walked `schema.grouping.fields` and
read `gf.field` off every entry with no guard, so a single `null` hole threw
`TypeError: Cannot read properties of null (reading 'field')` out of render —
the whole grid, gone, before any projection was built. `useGroupedData` is a
second dereference site of the same list (`fields[depth]` then `.field` /
`.order` / `.collapsed`), so guarding the memo alone would only have moved the
crash one call downstream.
Both sites now read one normalized entry list, `usableGroupingFields`. Its
admission rule is deliberately the harvester's: an entry is usable when it is
an object carrying a non-empty string `field` — exactly what
`collectGroupingFieldRefs` contributes to the query projection. Keeping the two
sets equal is load-bearing; an entry the grid grouped by but the projection
ignored would read `undefined` on every row and bucket every record into one
`(empty)` group, which is the silent wrong answer objectui#7179 closed. One bad
entry is dropped, not the grouping: the usable levels still group.
Measured before fixing, as the card asked: author-time validation ALREADY
refuses a null entry (`GroupingConfigSchema` types `fields` as an array of
`$strict` objects; objectui's `ListViewSchema` inherits it by reference), so
this is a defensive guard rather than a validation gap. The crash stays live
because nothing on the render path runs that validator — `ObjectGrid` reads
`schema.grouping` straight off its props and `validateSchema` is structural.
Both halves of that measurement are pinned in the new test file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-CukaXEZ6.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.50KB117.36KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 5eb7a8138, root-caused, fix in flight: Test (shard 4/4) fails only in scripts/__tests__/one-authority-per-exported-name-6273.test.ts — the new export type GroupingFieldEntry in packages/plugin-grid/src/useGroupedData.ts:213 collides with the GroupingFieldEntry interface packages/components/src/custom/grouping-editor.tsx:14 already declares (one authority per exported name, objectui#6273). Every other check on the head is green. The dev is resumed on the same branch to un-export or rename the alias and re-run the gate; no KNOWN_COLLISIONS line will be added.


Generated by Claude Code

…ty per name
`one-authority-per-exported-name-6273` went red: the alias introduced by the
null-entry guard published `GroupingFieldEntry`, a name `@object-ui/components`
already declares at `custom/grouping-editor.tsx:14`.
The two are genuinely different shapes, so the remedy is the gate's rename
branch rather than re-pointing one at the other: the components interface is
the grouping EDITOR's fully-populated value, with `order` and `collapsed`
REQUIRED, while this one is `z.input` of the spec's `GroupingConfig`, where
both carry defaults and are therefore optional. Collapsing them would make one
of the two lie.
Renamed to `UsableGroupingField` — free repo-wide, and it says what it is: an
entry that passed `usableGroupingFields`. The components interface is
untouched; it keeps the name it already owned. A comment at the declaration
records why the two names differ, so the next reader does not "unify" them.
No behaviour change — the rename is type-level only, and the grid pin is
unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-BshEx1BK.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.59KB117.40KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litant
os-litant marked this pull request as ready for review September 2, 2026 13:17
@os-litant
os-litant added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit e21308eSep 2, 2026
32 checks passed
@os-litant
os-litant deleted the claude/issue-7217-grouping-null-entry-guard branch September 2, 2026 13:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A null entry in grouping.fields[] throws a TypeError that takes down the whole grid, before any projection is built

2 participants

@os-litant@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(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid - #7346

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard
Sep 2, 2026
Merged

fix(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid#7346
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard

Conversation

@os-litant

@os-litantos-litant commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7217

Branch forked from origin/main at e75f4c986. Round-1 numbers were measured on 5eb7a8138; the Round 2 section at the end records the CI fix and what was re-run on the final commit 410823207.

Premise re-verified on the base sha

The card's claim holds, unchanged, at the fork point and on current origin/main:

  • packages/plugin-grid/src/ObjectGrid.tsx:2044-2045for (const gf of grouping.fields) { const fieldName = gf.field; with no guard.
  • packages/plugin-grid/src/useGroupedData.ts:251,257,260,268,278,290 — the SECOND dereference site: const f = fields[depth] and then f.field / f.order / f.collapsed.

origin/main moved to 2956d7af8 while this ran. git log e75f4c986..origin/main -- packages/plugin-grid packages/core packages/types shows PR #7284 and PR #7339 landed there, but both target lines are byte-identical and at the same line numbers on current origin/main, so the fork point was kept and the merge queue rebuilds.

The fix

Both sites now read ONE normalized entry list, usableGroupingFields, exported from useGroupedData.ts. An entry is usable when it is an object carrying a non-empty string field.

That admission rule is deliberately the harvester's — exactly what collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts) contributes to the query projection. Keeping the two sets equal is load-bearing, not tidiness: an entry the grid grouped by but the projection ignored would read undefined on every row and bucket every record into one (empty) group, which is the silent wrong answer objectui#7179 addressed. This is a defensive normalizer, not a lenient alias — no off-spec spelling is taught to mean anything; unusable entries are dropped, never coerced. And one bad entry drops the entry, not the grouping: the usable levels still group.

Routing the memo through collectGroupingFieldRefs itself was measured and rejected — see the table below.

Reachability measurement (the card's "Measure which before fixing")

QuestionAnswerEvidence
Does author-time validation reject a null entry?YESGroupingConfigSchema types fields as an array of $strict objects. safeParse({ fields: [null] }) fails with invalid_type at path fields.0. Declared in @objectstack/spec (view.zod d.ts lines 388-409); reached in this repo as @objectstack/spec/ui.
Does objectui's own ListViewSchema inherit that refusal?YESpackages/types/src/zod/objectql.zod.ts:427 imports the spec fields by reference via specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES), and grouping is NOT in LIST_VIEW_LOCAL_OVERRIDES (objectql.zod.ts:310-325), so the entry shape arrives with it.
Does any zod mirror in this repo re-declare the entry shape?NOgit grep -n grouping packages/types/src/zod/ hits only comments (objectql.zod.ts:382,410). The shape arrives by reference, never restated. The PM's assumption 4 is therefore half right: nothing is re-declared locally, but a refusing validator does exist.
Does the plugin-grid render path run any validator over schema.grouping?NONo safeParse / validateSchema call anywhere in packages/plugin-grid/src/** outside tests. @object-ui/core's validateSchema (packages/core/src/validation/schema-validator.ts:458) runs base + retired-node-type + form + children only; packages/core/src/validation/ contains zero occurrences of grouping — matching PR #7339's finding for this key too. ObjectGrid reads schema.grouping straight off its props.
Can the grouping toolbar popover write a hole?NOGroupingEditor.writeFields (packages/components/src/custom/grouping-editor.tsx:76-158) only ever builds object literals ({ ...g, field: e.target.value }, { field, order, collapsed }) and removes with .filter(), which cannot leave a hole. useGroupReorder.ts reorders rendered GROUP KEYS, never grouping.fields[] entries, so it is not a writer at all.
Is collectGroupingFieldRefs the whole fix?NOA GroupingFieldSchema entry carries order and collapsed besides field. The harvester answers with field NAMES, so routing the memo through it would lose exactly the two properties useGroupedData needs for sort order and default-collapsed state. Hence an entry-level normalizer that keeps the entry object.

This is a defensive guard, not a validation gap — but the crash stays live, because nothing on the render path runs the validator that refuses it. A runtime-composed, generated, or agent-written schema reaches the memo unparsed. Both halves of this measurement are pinned in the new test file rather than left as prose.

Not in this PR (for the PM)

There is nothing to file on the accept set: author-time validation already refuses a null entry, so no published validator's accept set needs to move and Clause-② stays "no". packages/types/src/** is untouched.

The measurement did surface one separate defect, filed as objectui#7347 and deliberately not touched here: a grouping field name with surrounding whitespace is accepted by GroupingFieldSchema (a bare z.ZodString), is trimmed by collectGroupingFieldRefs on its way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured: spec safeParse accepts { field: " business_unit " }; the harvester yields ["business_unit"]; the grid buckets by the raw padded key. It is the same silent-wrong-answer class as objectui#7179, and unlike the null entry it survives validation. The guard in this PR admits such an entry (its field is a non-empty string), leaving today's behaviour exactly as it is on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file.

Test evidence

New pin: packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (6 tests — 4 crash pins, 2 reachability measurements). The 7179 pin's narrowed malformed-block fixture was left exactly as it is.

RED first, on the unmodified tree:

× renders instead of throwing when the only grouping entry is null
× renders instead of throwing when the only grouping entry is undefined
× still groups by the usable entry when a null precedes it
× still groups by the usable entry when a null follows it at a deeper level
AssertionError: expected [Function] to not throw an error but
"TypeError: Cannot read properties of null (reading 'field')" was thrown
Tests 4 failed | 2 passed (6)

The 2 that passed on the unmodified tree are the reachability measurements — they are about the schema, not the guard, and are expected green in both directions.

Ablation, one site at a time

Each leg: mutate; prove the mutation landed on disk by counting the injected and the removed text (an edit tool's exit code proves nothing); run; restore with git checkout HEAD -- naming the file by ABSOLUTE path; prove the restore by blob hash against the HEAD blob plus an empty git diff HEAD. The script carried trap restore EXIT INT TERM throughout.

No build leg applies: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist/ in the path.

AblationMutation proven on diskResultRestore proven
A — remove ONLY the ObjectGrid memo guard (hook guard stays)injected for (const gf of grouping.fields) { x1, removed for (const gf of groupingFields) { x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash b8053e5e91d98da07cd30525417411b1c57c0d93 == HEAD blob, git diff HEAD empty
B — remove ONLY the useGroupedData guard (memo guard stays)injected const fields = config?.fields; x1, removed the useMemo(() => usableGroupingFields(rawFields), …) line x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash 98d98f05bdcf27510ff6d9c9f70229a13630b2f5 == HEAD blob, git diff HEAD empty

Both sites are independently load-bearing: guarding either one alone leaves all four crash pins red.

Verification table (round 1, on 5eb7a8138)

Every heavy run went through the shared verification lock (os-verify-lock.sh, slot dev-7217); the verdict quoted is the line the gate or the lock printed, never a bare $?.

WhatCommandVerdict line
New pinpnpm exec vitest run packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsxTest Files 1 passed (1) / Tests 6 passed (6) · VERDICT command-exit 0
plugin-grid suitepnpm exec vitest run packages/plugin-grid/Test Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
plugin-list suite (downstream grouping composer)pnpm exec vitest run packages/plugin-list/Test Files 62 passed (62) / Tests 787 passed (787) · VERDICT command-exit 0
Dependency closure buildpnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' buildVERDICT command-exit 0
Type-checkpnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
Lint, whole package (the CI-identical eslint .)pnpm exec eslint . --format json in packages/plugin-grid148 files, E=0, W=777 · VERDICT command-exit 0
Changeset presencenode scripts/check-changeset-presence.mjs✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7217-grouping-null-entry-guard.md.
Changeset no-majornode scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.
Control bytesnode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).
Phantom depsnode scripts/check-phantom-dependencies.mjsexit 0 — 40 released packages, 3736 source files, 18810 specifiers checked
Package self-importnode scripts/check-package-self-import.mjs✅ No package names itself inside its own src/.
vi.mock specifiersnode scripts/check-vi-mock-specifiers.mjs✅ check-vi-mock-specifiers: OK
vi.mock inheritnode scripts/check-vi-mock-inherit.mjs✅ check-vi-mock-inherit: OK
Governed surfacenode scripts/pm/check-governed-merges.mjs --test … (final file list)✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. (0 of 4 paths hit the register)

Type-check actually covers the new test file

tsc --noEmit alone would have said nothing about it. tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1, so the new test file is genuinely inside the type-check program rather than excluded and silently unmeasured.

Lint delta, against the fork-point blobs

Baseline taken by checking the two modified files out at e75f4c986 into the same paths (so eslint's config resolution is identical), proven landed on disk before measuring (usableGroupingFields occurrences = 0), then restored and proven by blob hash:

FileBase (e75f4c986)FinalDelta
ObjectGrid.tsxE=0 W=213E=0 W=2130 / 0
useGroupedData.tsE=0 W=26E=0 W=260 / 0
groupingNullEntry-7217.test.tsxnew fileE=0 W=1+1 warning

The one added warning is @typescript-eslint/no-explicit-any on const schema: any = { — the same idiom every sibling grid test uses (groupedBooleanLabel.test.tsx, groupingProjection-7179.test.tsx), and deliberate here: the fixture is intentionally off-spec, so a typed shape would refuse the null entry the test exists to feed in.

Round 2 — the CI gate this missed, and the fix

Test (shard 4/4) went red on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts (objectui#6273, ruling objectui#6172). Verbatim:

GroupingFieldEntry — a NEW colliding name
packages/components/src/custom/grouping-editor.tsx:14 — interface declaration
packages/plugin-grid/src/useGroupedData.ts:213 — type declaration

Round 1's alias published GroupingFieldEntry, a name @object-ui/components already owns.

The two are genuinely different shapes, so this took the gate's rename branch rather than re-pointing one at the other:

@object-ui/componentsGroupingFieldEntrythis alias
What it isthe grouping EDITOR's fully-populated valuethe AUTHORED spec entry (z.input of GroupingConfig)
orderrequired'asc' | 'desc'optional (schema default)
collapsedrequiredbooleanoptional (schema default)

Collapsing them would have made one of the two lie, so each keeps its own name. Renamed to UsableGroupingField — verified free across packages, apps and scripts before choosing — which also says what it is: an entry that passed usableGroupingFields. The components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. A comment at the declaration records why the two names differ, so the next reader does not "unify" them. KNOWN_COLLISIONS was NOT touched — it is shrink-only.

Type-level only, no behaviour change.

Reproduced RED locally first, then green, on the final commit 410823207:

WhatVerdict line
The failing gate, before the renameTests 1 failed | 10 passed (11) · VERDICT command-exit 1 — same GroupingFieldEntry — a NEW colliding name message as CI
The gate + the pin, after the renameTest Files 2 passed (2) / Tests 17 passed (17) · VERDICT command-exit 0
The whole scripts/ meta-gate familyTest Files 97 passed (97) / Tests 2738 passed (2738) · VERDICT command-exit 0
plugin-grid suiteTest Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
Closure build + type-checkVERDICT command-exit 0
Lint, useGroupedData.tsE=0 W=26 — unchanged from base · VERDICT command-exit 0
Control bytes✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).

Round 1's local gate derivation was package-scoped — the touched package's suites, type-check and eslint ., plus the check:* scripts whose subject matter the diff touched. It never asked whether a repo-wide gate in scripts/__tests__/ reads the diff, and this one does: it scans every exported type name in the monorepo, so adding one exported type anywhere can redden it. Round 2 therefore ran all 97 of those files rather than only the one that failed, which is the derivation round 1 should have made.

Hot-file constraint (PR #7284)

Honoured. git diff -U0 on ObjectGrid.tsx reports exactly three hunk headers: @@ -43 +43 @@, @@ -2037,2 +2037,9 @@ and @@ -2044 +2051 @@ — the import line 43 plus the grouping memo region. Line 39 (the @object-ui/core import) and the fieldsToShow default-column synthesis at roughly 2788-2810 are untouched — no new @object-ui/core export was needed, because usableGroupingFields lives in plugin-grid's own useGroupedData.ts and rides the existing line-43 ./useGroupedData import. Round 2 touched only useGroupedData.ts.

packages/core/src/utils/grouping-fields.ts was READ as the route candidate and deliberately not edited: the harvester is already correct, and its name-only return is why it cannot be the whole fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

…es the grid
`ObjectGrid`'s `groupValueFormatter` memo walked `schema.grouping.fields` and
read `gf.field` off every entry with no guard, so a single `null` hole threw
`TypeError: Cannot read properties of null (reading 'field')` out of render —
the whole grid, gone, before any projection was built. `useGroupedData` is a
second dereference site of the same list (`fields[depth]` then `.field` /
`.order` / `.collapsed`), so guarding the memo alone would only have moved the
crash one call downstream.
Both sites now read one normalized entry list, `usableGroupingFields`. Its
admission rule is deliberately the harvester's: an entry is usable when it is
an object carrying a non-empty string `field` — exactly what
`collectGroupingFieldRefs` contributes to the query projection. Keeping the two
sets equal is load-bearing; an entry the grid grouped by but the projection
ignored would read `undefined` on every row and bucket every record into one
`(empty)` group, which is the silent wrong answer objectui#7179 closed. One bad
entry is dropped, not the grouping: the usable levels still group.
Measured before fixing, as the card asked: author-time validation ALREADY
refuses a null entry (`GroupingConfigSchema` types `fields` as an array of
`$strict` objects; objectui's `ListViewSchema` inherits it by reference), so
this is a defensive guard rather than a validation gap. The crash stays live
because nothing on the render path runs that validator — `ObjectGrid` reads
`schema.grouping` straight off its props and `validateSchema` is structural.
Both halves of that measurement are pinned in the new test file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-CukaXEZ6.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.50KB117.36KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 5eb7a8138, root-caused, fix in flight: Test (shard 4/4) fails only in scripts/__tests__/one-authority-per-exported-name-6273.test.ts — the new export type GroupingFieldEntry in packages/plugin-grid/src/useGroupedData.ts:213 collides with the GroupingFieldEntry interface packages/components/src/custom/grouping-editor.tsx:14 already declares (one authority per exported name, objectui#6273). Every other check on the head is green. The dev is resumed on the same branch to un-export or rename the alias and re-run the gate; no KNOWN_COLLISIONS line will be added.


Generated by Claude Code

…ty per name
`one-authority-per-exported-name-6273` went red: the alias introduced by the
null-entry guard published `GroupingFieldEntry`, a name `@object-ui/components`
already declares at `custom/grouping-editor.tsx:14`.
The two are genuinely different shapes, so the remedy is the gate's rename
branch rather than re-pointing one at the other: the components interface is
the grouping EDITOR's fully-populated value, with `order` and `collapsed`
REQUIRED, while this one is `z.input` of the spec's `GroupingConfig`, where
both carry defaults and are therefore optional. Collapsing them would make one
of the two lie.
Renamed to `UsableGroupingField` — free repo-wide, and it says what it is: an
entry that passed `usableGroupingFields`. The components interface is
untouched; it keeps the name it already owned. A comment at the declaration
records why the two names differ, so the next reader does not "unify" them.
No behaviour change — the rename is type-level only, and the grid pin is
unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-BshEx1BK.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.59KB117.40KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litant
os-litant marked this pull request as ready for review September 2, 2026 13:17
@os-litant
os-litant added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit e21308eSep 2, 2026
32 checks passed
@os-litant
os-litant deleted the claude/issue-7217-grouping-null-entry-guard branch September 2, 2026 13:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A null entry in grouping.fields[] throws a TypeError that takes down the whole grid, before any projection is built

2 participants

@os-litant@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(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid - #7346

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard
Sep 2, 2026
Merged

fix(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid#7346
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard

Conversation

@os-litant

@os-litantos-litant commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7217

Branch forked from origin/main at e75f4c986. Round-1 numbers were measured on 5eb7a8138; the Round 2 section at the end records the CI fix and what was re-run on the final commit 410823207.

Premise re-verified on the base sha

The card's claim holds, unchanged, at the fork point and on current origin/main:

  • packages/plugin-grid/src/ObjectGrid.tsx:2044-2045for (const gf of grouping.fields) { const fieldName = gf.field; with no guard.
  • packages/plugin-grid/src/useGroupedData.ts:251,257,260,268,278,290 — the SECOND dereference site: const f = fields[depth] and then f.field / f.order / f.collapsed.

origin/main moved to 2956d7af8 while this ran. git log e75f4c986..origin/main -- packages/plugin-grid packages/core packages/types shows PR #7284 and PR #7339 landed there, but both target lines are byte-identical and at the same line numbers on current origin/main, so the fork point was kept and the merge queue rebuilds.

The fix

Both sites now read ONE normalized entry list, usableGroupingFields, exported from useGroupedData.ts. An entry is usable when it is an object carrying a non-empty string field.

That admission rule is deliberately the harvester's — exactly what collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts) contributes to the query projection. Keeping the two sets equal is load-bearing, not tidiness: an entry the grid grouped by but the projection ignored would read undefined on every row and bucket every record into one (empty) group, which is the silent wrong answer objectui#7179 addressed. This is a defensive normalizer, not a lenient alias — no off-spec spelling is taught to mean anything; unusable entries are dropped, never coerced. And one bad entry drops the entry, not the grouping: the usable levels still group.

Routing the memo through collectGroupingFieldRefs itself was measured and rejected — see the table below.

Reachability measurement (the card's "Measure which before fixing")

QuestionAnswerEvidence
Does author-time validation reject a null entry?YESGroupingConfigSchema types fields as an array of $strict objects. safeParse({ fields: [null] }) fails with invalid_type at path fields.0. Declared in @objectstack/spec (view.zod d.ts lines 388-409); reached in this repo as @objectstack/spec/ui.
Does objectui's own ListViewSchema inherit that refusal?YESpackages/types/src/zod/objectql.zod.ts:427 imports the spec fields by reference via specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES), and grouping is NOT in LIST_VIEW_LOCAL_OVERRIDES (objectql.zod.ts:310-325), so the entry shape arrives with it.
Does any zod mirror in this repo re-declare the entry shape?NOgit grep -n grouping packages/types/src/zod/ hits only comments (objectql.zod.ts:382,410). The shape arrives by reference, never restated. The PM's assumption 4 is therefore half right: nothing is re-declared locally, but a refusing validator does exist.
Does the plugin-grid render path run any validator over schema.grouping?NONo safeParse / validateSchema call anywhere in packages/plugin-grid/src/** outside tests. @object-ui/core's validateSchema (packages/core/src/validation/schema-validator.ts:458) runs base + retired-node-type + form + children only; packages/core/src/validation/ contains zero occurrences of grouping — matching PR #7339's finding for this key too. ObjectGrid reads schema.grouping straight off its props.
Can the grouping toolbar popover write a hole?NOGroupingEditor.writeFields (packages/components/src/custom/grouping-editor.tsx:76-158) only ever builds object literals ({ ...g, field: e.target.value }, { field, order, collapsed }) and removes with .filter(), which cannot leave a hole. useGroupReorder.ts reorders rendered GROUP KEYS, never grouping.fields[] entries, so it is not a writer at all.
Is collectGroupingFieldRefs the whole fix?NOA GroupingFieldSchema entry carries order and collapsed besides field. The harvester answers with field NAMES, so routing the memo through it would lose exactly the two properties useGroupedData needs for sort order and default-collapsed state. Hence an entry-level normalizer that keeps the entry object.

This is a defensive guard, not a validation gap — but the crash stays live, because nothing on the render path runs the validator that refuses it. A runtime-composed, generated, or agent-written schema reaches the memo unparsed. Both halves of this measurement are pinned in the new test file rather than left as prose.

Not in this PR (for the PM)

There is nothing to file on the accept set: author-time validation already refuses a null entry, so no published validator's accept set needs to move and Clause-② stays "no". packages/types/src/** is untouched.

The measurement did surface one separate defect, filed as objectui#7347 and deliberately not touched here: a grouping field name with surrounding whitespace is accepted by GroupingFieldSchema (a bare z.ZodString), is trimmed by collectGroupingFieldRefs on its way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured: spec safeParse accepts { field: " business_unit " }; the harvester yields ["business_unit"]; the grid buckets by the raw padded key. It is the same silent-wrong-answer class as objectui#7179, and unlike the null entry it survives validation. The guard in this PR admits such an entry (its field is a non-empty string), leaving today's behaviour exactly as it is on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file.

Test evidence

New pin: packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (6 tests — 4 crash pins, 2 reachability measurements). The 7179 pin's narrowed malformed-block fixture was left exactly as it is.

RED first, on the unmodified tree:

× renders instead of throwing when the only grouping entry is null
× renders instead of throwing when the only grouping entry is undefined
× still groups by the usable entry when a null precedes it
× still groups by the usable entry when a null follows it at a deeper level
AssertionError: expected [Function] to not throw an error but
"TypeError: Cannot read properties of null (reading 'field')" was thrown
Tests 4 failed | 2 passed (6)

The 2 that passed on the unmodified tree are the reachability measurements — they are about the schema, not the guard, and are expected green in both directions.

Ablation, one site at a time

Each leg: mutate; prove the mutation landed on disk by counting the injected and the removed text (an edit tool's exit code proves nothing); run; restore with git checkout HEAD -- naming the file by ABSOLUTE path; prove the restore by blob hash against the HEAD blob plus an empty git diff HEAD. The script carried trap restore EXIT INT TERM throughout.

No build leg applies: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist/ in the path.

AblationMutation proven on diskResultRestore proven
A — remove ONLY the ObjectGrid memo guard (hook guard stays)injected for (const gf of grouping.fields) { x1, removed for (const gf of groupingFields) { x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash b8053e5e91d98da07cd30525417411b1c57c0d93 == HEAD blob, git diff HEAD empty
B — remove ONLY the useGroupedData guard (memo guard stays)injected const fields = config?.fields; x1, removed the useMemo(() => usableGroupingFields(rawFields), …) line x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash 98d98f05bdcf27510ff6d9c9f70229a13630b2f5 == HEAD blob, git diff HEAD empty

Both sites are independently load-bearing: guarding either one alone leaves all four crash pins red.

Verification table (round 1, on 5eb7a8138)

Every heavy run went through the shared verification lock (os-verify-lock.sh, slot dev-7217); the verdict quoted is the line the gate or the lock printed, never a bare $?.

WhatCommandVerdict line
New pinpnpm exec vitest run packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsxTest Files 1 passed (1) / Tests 6 passed (6) · VERDICT command-exit 0
plugin-grid suitepnpm exec vitest run packages/plugin-grid/Test Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
plugin-list suite (downstream grouping composer)pnpm exec vitest run packages/plugin-list/Test Files 62 passed (62) / Tests 787 passed (787) · VERDICT command-exit 0
Dependency closure buildpnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' buildVERDICT command-exit 0
Type-checkpnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
Lint, whole package (the CI-identical eslint .)pnpm exec eslint . --format json in packages/plugin-grid148 files, E=0, W=777 · VERDICT command-exit 0
Changeset presencenode scripts/check-changeset-presence.mjs✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7217-grouping-null-entry-guard.md.
Changeset no-majornode scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.
Control bytesnode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).
Phantom depsnode scripts/check-phantom-dependencies.mjsexit 0 — 40 released packages, 3736 source files, 18810 specifiers checked
Package self-importnode scripts/check-package-self-import.mjs✅ No package names itself inside its own src/.
vi.mock specifiersnode scripts/check-vi-mock-specifiers.mjs✅ check-vi-mock-specifiers: OK
vi.mock inheritnode scripts/check-vi-mock-inherit.mjs✅ check-vi-mock-inherit: OK
Governed surfacenode scripts/pm/check-governed-merges.mjs --test … (final file list)✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. (0 of 4 paths hit the register)

Type-check actually covers the new test file

tsc --noEmit alone would have said nothing about it. tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1, so the new test file is genuinely inside the type-check program rather than excluded and silently unmeasured.

Lint delta, against the fork-point blobs

Baseline taken by checking the two modified files out at e75f4c986 into the same paths (so eslint's config resolution is identical), proven landed on disk before measuring (usableGroupingFields occurrences = 0), then restored and proven by blob hash:

FileBase (e75f4c986)FinalDelta
ObjectGrid.tsxE=0 W=213E=0 W=2130 / 0
useGroupedData.tsE=0 W=26E=0 W=260 / 0
groupingNullEntry-7217.test.tsxnew fileE=0 W=1+1 warning

The one added warning is @typescript-eslint/no-explicit-any on const schema: any = { — the same idiom every sibling grid test uses (groupedBooleanLabel.test.tsx, groupingProjection-7179.test.tsx), and deliberate here: the fixture is intentionally off-spec, so a typed shape would refuse the null entry the test exists to feed in.

Round 2 — the CI gate this missed, and the fix

Test (shard 4/4) went red on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts (objectui#6273, ruling objectui#6172). Verbatim:

GroupingFieldEntry — a NEW colliding name
packages/components/src/custom/grouping-editor.tsx:14 — interface declaration
packages/plugin-grid/src/useGroupedData.ts:213 — type declaration

Round 1's alias published GroupingFieldEntry, a name @object-ui/components already owns.

The two are genuinely different shapes, so this took the gate's rename branch rather than re-pointing one at the other:

@object-ui/componentsGroupingFieldEntrythis alias
What it isthe grouping EDITOR's fully-populated valuethe AUTHORED spec entry (z.input of GroupingConfig)
orderrequired'asc' | 'desc'optional (schema default)
collapsedrequiredbooleanoptional (schema default)

Collapsing them would have made one of the two lie, so each keeps its own name. Renamed to UsableGroupingField — verified free across packages, apps and scripts before choosing — which also says what it is: an entry that passed usableGroupingFields. The components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. A comment at the declaration records why the two names differ, so the next reader does not "unify" them. KNOWN_COLLISIONS was NOT touched — it is shrink-only.

Type-level only, no behaviour change.

Reproduced RED locally first, then green, on the final commit 410823207:

WhatVerdict line
The failing gate, before the renameTests 1 failed | 10 passed (11) · VERDICT command-exit 1 — same GroupingFieldEntry — a NEW colliding name message as CI
The gate + the pin, after the renameTest Files 2 passed (2) / Tests 17 passed (17) · VERDICT command-exit 0
The whole scripts/ meta-gate familyTest Files 97 passed (97) / Tests 2738 passed (2738) · VERDICT command-exit 0
plugin-grid suiteTest Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
Closure build + type-checkVERDICT command-exit 0
Lint, useGroupedData.tsE=0 W=26 — unchanged from base · VERDICT command-exit 0
Control bytes✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).

Round 1's local gate derivation was package-scoped — the touched package's suites, type-check and eslint ., plus the check:* scripts whose subject matter the diff touched. It never asked whether a repo-wide gate in scripts/__tests__/ reads the diff, and this one does: it scans every exported type name in the monorepo, so adding one exported type anywhere can redden it. Round 2 therefore ran all 97 of those files rather than only the one that failed, which is the derivation round 1 should have made.

Hot-file constraint (PR #7284)

Honoured. git diff -U0 on ObjectGrid.tsx reports exactly three hunk headers: @@ -43 +43 @@, @@ -2037,2 +2037,9 @@ and @@ -2044 +2051 @@ — the import line 43 plus the grouping memo region. Line 39 (the @object-ui/core import) and the fieldsToShow default-column synthesis at roughly 2788-2810 are untouched — no new @object-ui/core export was needed, because usableGroupingFields lives in plugin-grid's own useGroupedData.ts and rides the existing line-43 ./useGroupedData import. Round 2 touched only useGroupedData.ts.

packages/core/src/utils/grouping-fields.ts was READ as the route candidate and deliberately not edited: the harvester is already correct, and its name-only return is why it cannot be the whole fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

…es the grid
`ObjectGrid`'s `groupValueFormatter` memo walked `schema.grouping.fields` and
read `gf.field` off every entry with no guard, so a single `null` hole threw
`TypeError: Cannot read properties of null (reading 'field')` out of render —
the whole grid, gone, before any projection was built. `useGroupedData` is a
second dereference site of the same list (`fields[depth]` then `.field` /
`.order` / `.collapsed`), so guarding the memo alone would only have moved the
crash one call downstream.
Both sites now read one normalized entry list, `usableGroupingFields`. Its
admission rule is deliberately the harvester's: an entry is usable when it is
an object carrying a non-empty string `field` — exactly what
`collectGroupingFieldRefs` contributes to the query projection. Keeping the two
sets equal is load-bearing; an entry the grid grouped by but the projection
ignored would read `undefined` on every row and bucket every record into one
`(empty)` group, which is the silent wrong answer objectui#7179 closed. One bad
entry is dropped, not the grouping: the usable levels still group.
Measured before fixing, as the card asked: author-time validation ALREADY
refuses a null entry (`GroupingConfigSchema` types `fields` as an array of
`$strict` objects; objectui's `ListViewSchema` inherits it by reference), so
this is a defensive guard rather than a validation gap. The crash stays live
because nothing on the render path runs that validator — `ObjectGrid` reads
`schema.grouping` straight off its props and `validateSchema` is structural.
Both halves of that measurement are pinned in the new test file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-CukaXEZ6.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.50KB117.36KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 5eb7a8138, root-caused, fix in flight: Test (shard 4/4) fails only in scripts/__tests__/one-authority-per-exported-name-6273.test.ts — the new export type GroupingFieldEntry in packages/plugin-grid/src/useGroupedData.ts:213 collides with the GroupingFieldEntry interface packages/components/src/custom/grouping-editor.tsx:14 already declares (one authority per exported name, objectui#6273). Every other check on the head is green. The dev is resumed on the same branch to un-export or rename the alias and re-run the gate; no KNOWN_COLLISIONS line will be added.


Generated by Claude Code

…ty per name
`one-authority-per-exported-name-6273` went red: the alias introduced by the
null-entry guard published `GroupingFieldEntry`, a name `@object-ui/components`
already declares at `custom/grouping-editor.tsx:14`.
The two are genuinely different shapes, so the remedy is the gate's rename
branch rather than re-pointing one at the other: the components interface is
the grouping EDITOR's fully-populated value, with `order` and `collapsed`
REQUIRED, while this one is `z.input` of the spec's `GroupingConfig`, where
both carry defaults and are therefore optional. Collapsing them would make one
of the two lie.
Renamed to `UsableGroupingField` — free repo-wide, and it says what it is: an
entry that passed `usableGroupingFields`. The components interface is
untouched; it keeps the name it already owned. A comment at the declaration
records why the two names differ, so the next reader does not "unify" them.
No behaviour change — the rename is type-level only, and the grid pin is
unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-BshEx1BK.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.59KB117.40KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litant
os-litant marked this pull request as ready for review September 2, 2026 13:17
@os-litant
os-litant added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit e21308eSep 2, 2026
32 checks passed
@os-litant
os-litant deleted the claude/issue-7217-grouping-null-entry-guard branch September 2, 2026 13:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A null entry in grouping.fields[] throws a TypeError that takes down the whole grid, before any projection is built

2 participants

@os-litant@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(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid - #7346

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard
Sep 2, 2026
Merged

fix(plugin-grid): a null entry in grouping.fields[] no longer crashes the grid#7346
os-litant merged 2 commits into
mainfrom
claude/issue-7217-grouping-null-entry-guard

Conversation

@os-litant

@os-litantos-litant commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7217

Branch forked from origin/main at e75f4c986. Round-1 numbers were measured on 5eb7a8138; the Round 2 section at the end records the CI fix and what was re-run on the final commit 410823207.

Premise re-verified on the base sha

The card's claim holds, unchanged, at the fork point and on current origin/main:

  • packages/plugin-grid/src/ObjectGrid.tsx:2044-2045for (const gf of grouping.fields) { const fieldName = gf.field; with no guard.
  • packages/plugin-grid/src/useGroupedData.ts:251,257,260,268,278,290 — the SECOND dereference site: const f = fields[depth] and then f.field / f.order / f.collapsed.

origin/main moved to 2956d7af8 while this ran. git log e75f4c986..origin/main -- packages/plugin-grid packages/core packages/types shows PR #7284 and PR #7339 landed there, but both target lines are byte-identical and at the same line numbers on current origin/main, so the fork point was kept and the merge queue rebuilds.

The fix

Both sites now read ONE normalized entry list, usableGroupingFields, exported from useGroupedData.ts. An entry is usable when it is an object carrying a non-empty string field.

That admission rule is deliberately the harvester's — exactly what collectGroupingFieldRefs (packages/core/src/utils/grouping-fields.ts) contributes to the query projection. Keeping the two sets equal is load-bearing, not tidiness: an entry the grid grouped by but the projection ignored would read undefined on every row and bucket every record into one (empty) group, which is the silent wrong answer objectui#7179 addressed. This is a defensive normalizer, not a lenient alias — no off-spec spelling is taught to mean anything; unusable entries are dropped, never coerced. And one bad entry drops the entry, not the grouping: the usable levels still group.

Routing the memo through collectGroupingFieldRefs itself was measured and rejected — see the table below.

Reachability measurement (the card's "Measure which before fixing")

QuestionAnswerEvidence
Does author-time validation reject a null entry?YESGroupingConfigSchema types fields as an array of $strict objects. safeParse({ fields: [null] }) fails with invalid_type at path fields.0. Declared in @objectstack/spec (view.zod d.ts lines 388-409); reached in this repo as @objectstack/spec/ui.
Does objectui's own ListViewSchema inherit that refusal?YESpackages/types/src/zod/objectql.zod.ts:427 imports the spec fields by reference via specFieldsExcept(SpecListViewSchema.shape, LIST_VIEW_LOCAL_OVERRIDES), and grouping is NOT in LIST_VIEW_LOCAL_OVERRIDES (objectql.zod.ts:310-325), so the entry shape arrives with it.
Does any zod mirror in this repo re-declare the entry shape?NOgit grep -n grouping packages/types/src/zod/ hits only comments (objectql.zod.ts:382,410). The shape arrives by reference, never restated. The PM's assumption 4 is therefore half right: nothing is re-declared locally, but a refusing validator does exist.
Does the plugin-grid render path run any validator over schema.grouping?NONo safeParse / validateSchema call anywhere in packages/plugin-grid/src/** outside tests. @object-ui/core's validateSchema (packages/core/src/validation/schema-validator.ts:458) runs base + retired-node-type + form + children only; packages/core/src/validation/ contains zero occurrences of grouping — matching PR #7339's finding for this key too. ObjectGrid reads schema.grouping straight off its props.
Can the grouping toolbar popover write a hole?NOGroupingEditor.writeFields (packages/components/src/custom/grouping-editor.tsx:76-158) only ever builds object literals ({ ...g, field: e.target.value }, { field, order, collapsed }) and removes with .filter(), which cannot leave a hole. useGroupReorder.ts reorders rendered GROUP KEYS, never grouping.fields[] entries, so it is not a writer at all.
Is collectGroupingFieldRefs the whole fix?NOA GroupingFieldSchema entry carries order and collapsed besides field. The harvester answers with field NAMES, so routing the memo through it would lose exactly the two properties useGroupedData needs for sort order and default-collapsed state. Hence an entry-level normalizer that keeps the entry object.

This is a defensive guard, not a validation gap — but the crash stays live, because nothing on the render path runs the validator that refuses it. A runtime-composed, generated, or agent-written schema reaches the memo unparsed. Both halves of this measurement are pinned in the new test file rather than left as prose.

Not in this PR (for the PM)

There is nothing to file on the accept set: author-time validation already refuses a null entry, so no published validator's accept set needs to move and Clause-② stays "no". packages/types/src/** is untouched.

The measurement did surface one separate defect, filed as objectui#7347 and deliberately not touched here: a grouping field name with surrounding whitespace is accepted by GroupingFieldSchema (a bare z.ZodString), is trimmed by collectGroupingFieldRefs on its way into $select, but is used untrimmed as the bucket key by both grid sites — so every row reads undefined and lands in one (empty) group. Measured: spec safeParse accepts { field: " business_unit " }; the harvester yields ["business_unit"]; the grid buckets by the raw padded key. It is the same silent-wrong-answer class as objectui#7179, and unlike the null entry it survives validation. The guard in this PR admits such an entry (its field is a non-empty string), leaving today's behaviour exactly as it is on main; normalizing the value as well as the admission set would be a behaviour change beyond this card, on a hot file.

Test evidence

New pin: packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsx (6 tests — 4 crash pins, 2 reachability measurements). The 7179 pin's narrowed malformed-block fixture was left exactly as it is.

RED first, on the unmodified tree:

× renders instead of throwing when the only grouping entry is null
× renders instead of throwing when the only grouping entry is undefined
× still groups by the usable entry when a null precedes it
× still groups by the usable entry when a null follows it at a deeper level
AssertionError: expected [Function] to not throw an error but
"TypeError: Cannot read properties of null (reading 'field')" was thrown
Tests 4 failed | 2 passed (6)

The 2 that passed on the unmodified tree are the reachability measurements — they are about the schema, not the guard, and are expected green in both directions.

Ablation, one site at a time

Each leg: mutate; prove the mutation landed on disk by counting the injected and the removed text (an edit tool's exit code proves nothing); run; restore with git checkout HEAD -- naming the file by ABSOLUTE path; prove the restore by blob hash against the HEAD blob plus an empty git diff HEAD. The script carried trap restore EXIT INT TERM throughout.

No build leg applies: the pin imports ../ObjectGrid relatively and the root vitest config aliases @object-ui/* to each package's src, so both mutation targets are read from source with no dist/ in the path.

AblationMutation proven on diskResultRestore proven
A — remove ONLY the ObjectGrid memo guard (hook guard stays)injected for (const gf of grouping.fields) { x1, removed for (const gf of groupingFields) { x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash b8053e5e91d98da07cd30525417411b1c57c0d93 == HEAD blob, git diff HEAD empty
B — remove ONLY the useGroupedData guard (memo guard stays)injected const fields = config?.fields; x1, removed the useMemo(() => usableGroupingFields(rawFields), …) line x0RED, exit 1, Tests 4 failed | 2 passed (6), first error TypeError: Cannot read properties of null (reading 'field')hash 98d98f05bdcf27510ff6d9c9f70229a13630b2f5 == HEAD blob, git diff HEAD empty

Both sites are independently load-bearing: guarding either one alone leaves all four crash pins red.

Verification table (round 1, on 5eb7a8138)

Every heavy run went through the shared verification lock (os-verify-lock.sh, slot dev-7217); the verdict quoted is the line the gate or the lock printed, never a bare $?.

WhatCommandVerdict line
New pinpnpm exec vitest run packages/plugin-grid/src/__tests__/groupingNullEntry-7217.test.tsxTest Files 1 passed (1) / Tests 6 passed (6) · VERDICT command-exit 0
plugin-grid suitepnpm exec vitest run packages/plugin-grid/Test Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
plugin-list suite (downstream grouping composer)pnpm exec vitest run packages/plugin-list/Test Files 62 passed (62) / Tests 787 passed (787) · VERDICT command-exit 0
Dependency closure buildpnpm --workspace-concurrency=2 --filter '@object-ui/plugin-grid^...' buildVERDICT command-exit 0
Type-checkpnpm --filter @object-ui/plugin-grid run type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
Lint, whole package (the CI-identical eslint .)pnpm exec eslint . --format json in packages/plugin-grid148 files, E=0, W=777 · VERDICT command-exit 0
Changeset presencenode scripts/check-changeset-presence.mjs✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7217-grouping-null-entry-guard.md.
Changeset no-majornode scripts/check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.
Control bytesnode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).
Phantom depsnode scripts/check-phantom-dependencies.mjsexit 0 — 40 released packages, 3736 source files, 18810 specifiers checked
Package self-importnode scripts/check-package-self-import.mjs✅ No package names itself inside its own src/.
vi.mock specifiersnode scripts/check-vi-mock-specifiers.mjs✅ check-vi-mock-specifiers: OK
vi.mock inheritnode scripts/check-vi-mock-inherit.mjs✅ check-vi-mock-inherit: OK
Governed surfacenode scripts/pm/check-governed-merges.mjs --test … (final file list)✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list. (0 of 4 paths hit the register)

Type-check actually covers the new test file

tsc --noEmit alone would have said nothing about it. tsc -p tsconfig.test.json --listFiles | grep -c groupingNullEntry-7217 = 1, so the new test file is genuinely inside the type-check program rather than excluded and silently unmeasured.

Lint delta, against the fork-point blobs

Baseline taken by checking the two modified files out at e75f4c986 into the same paths (so eslint's config resolution is identical), proven landed on disk before measuring (usableGroupingFields occurrences = 0), then restored and proven by blob hash:

FileBase (e75f4c986)FinalDelta
ObjectGrid.tsxE=0 W=213E=0 W=2130 / 0
useGroupedData.tsE=0 W=26E=0 W=260 / 0
groupingNullEntry-7217.test.tsxnew fileE=0 W=1+1 warning

The one added warning is @typescript-eslint/no-explicit-any on const schema: any = { — the same idiom every sibling grid test uses (groupedBooleanLabel.test.tsx, groupingProjection-7179.test.tsx), and deliberate here: the fixture is intentionally off-spec, so a typed shape would refuse the null entry the test exists to feed in.

Round 2 — the CI gate this missed, and the fix

Test (shard 4/4) went red on 5eb7a8138 in scripts/__tests__/one-authority-per-exported-name-6273.test.ts (objectui#6273, ruling objectui#6172). Verbatim:

GroupingFieldEntry — a NEW colliding name
packages/components/src/custom/grouping-editor.tsx:14 — interface declaration
packages/plugin-grid/src/useGroupedData.ts:213 — type declaration

Round 1's alias published GroupingFieldEntry, a name @object-ui/components already owns.

The two are genuinely different shapes, so this took the gate's rename branch rather than re-pointing one at the other:

@object-ui/componentsGroupingFieldEntrythis alias
What it isthe grouping EDITOR's fully-populated valuethe AUTHORED spec entry (z.input of GroupingConfig)
orderrequired'asc' | 'desc'optional (schema default)
collapsedrequiredbooleanoptional (schema default)

Collapsing them would have made one of the two lie, so each keeps its own name. Renamed to UsableGroupingField — verified free across packages, apps and scripts before choosing — which also says what it is: an entry that passed usableGroupingFields. The components interface is untouched and keeps the name it already owned; nothing outside useGroupedData.ts referenced the alias. A comment at the declaration records why the two names differ, so the next reader does not "unify" them. KNOWN_COLLISIONS was NOT touched — it is shrink-only.

Type-level only, no behaviour change.

Reproduced RED locally first, then green, on the final commit 410823207:

WhatVerdict line
The failing gate, before the renameTests 1 failed | 10 passed (11) · VERDICT command-exit 1 — same GroupingFieldEntry — a NEW colliding name message as CI
The gate + the pin, after the renameTest Files 2 passed (2) / Tests 17 passed (17) · VERDICT command-exit 0
The whole scripts/ meta-gate familyTest Files 97 passed (97) / Tests 2738 passed (2738) · VERDICT command-exit 0
plugin-grid suiteTest Files 110 passed (110) / Tests 999 passed (999) · VERDICT command-exit 0
Closure build + type-checkVERDICT command-exit 0
Lint, useGroupedData.tsE=0 W=26 — unchanged from base · VERDICT command-exit 0
Control bytes✅ check-control-bytes: OK (scanned 6037 tracked text file(s); skipped 85 binary).

Round 1's local gate derivation was package-scoped — the touched package's suites, type-check and eslint ., plus the check:* scripts whose subject matter the diff touched. It never asked whether a repo-wide gate in scripts/__tests__/ reads the diff, and this one does: it scans every exported type name in the monorepo, so adding one exported type anywhere can redden it. Round 2 therefore ran all 97 of those files rather than only the one that failed, which is the derivation round 1 should have made.

Hot-file constraint (PR #7284)

Honoured. git diff -U0 on ObjectGrid.tsx reports exactly three hunk headers: @@ -43 +43 @@, @@ -2037,2 +2037,9 @@ and @@ -2044 +2051 @@ — the import line 43 plus the grouping memo region. Line 39 (the @object-ui/core import) and the fieldsToShow default-column synthesis at roughly 2788-2810 are untouched — no new @object-ui/core export was needed, because usableGroupingFields lives in plugin-grid's own useGroupedData.ts and rides the existing line-43 ./useGroupedData import. Round 2 touched only useGroupedData.ts.

packages/core/src/utils/grouping-fields.ts was READ as the route candidate and deliberately not edited: the harvester is already correct, and its name-only return is why it cannot be the whole fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho

…es the grid
`ObjectGrid`'s `groupValueFormatter` memo walked `schema.grouping.fields` and
read `gf.field` off every entry with no guard, so a single `null` hole threw
`TypeError: Cannot read properties of null (reading 'field')` out of render —
the whole grid, gone, before any projection was built. `useGroupedData` is a
second dereference site of the same list (`fields[depth]` then `.field` /
`.order` / `.collapsed`), so guarding the memo alone would only have moved the
crash one call downstream.
Both sites now read one normalized entry list, `usableGroupingFields`. Its
admission rule is deliberately the harvester's: an entry is usable when it is
an object carrying a non-empty string `field` — exactly what
`collectGroupingFieldRefs` contributes to the query projection. Keeping the two
sets equal is load-bearing; an entry the grid grouped by but the projection
ignored would read `undefined` on every row and bucket every record into one
`(empty)` group, which is the silent wrong answer objectui#7179 closed. One bad
entry is dropped, not the grouping: the usable levels still group.
Measured before fixing, as the card asked: author-time validation ALREADY
refuses a null entry (`GroupingConfigSchema` types `fields` as an array of
`$strict` objects; objectui's `ListViewSchema` inherits it by reference), so
this is a defensive guard rather than a validation gap. The crash stays live
because nothing on the render path runs that validator — `ObjectGrid` reads
`schema.grouping` straight off its props and `validateSchema` is structural.
Both halves of that measurement are pinned in the new test file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-CukaXEZ6.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.50KB117.36KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI red on 5eb7a8138, root-caused, fix in flight: Test (shard 4/4) fails only in scripts/__tests__/one-authority-per-exported-name-6273.test.ts — the new export type GroupingFieldEntry in packages/plugin-grid/src/useGroupedData.ts:213 collides with the GroupingFieldEntry interface packages/components/src/custom/grouping-editor.tsx:14 already declares (one authority per exported name, objectui#6273). Every other check on the head is green. The dev is resumed on the same branch to un-export or rename the alias and re-run the gate; no KNOWN_COLLISIONS line will be added.


Generated by Claude Code

…ty per name
`one-authority-per-exported-name-6273` went red: the alias introduced by the
null-entry guard published `GroupingFieldEntry`, a name `@object-ui/components`
already declares at `custom/grouping-editor.tsx:14`.
The two are genuinely different shapes, so the remedy is the gate's rename
branch rather than re-pointing one at the other: the components interface is
the grouping EDITOR's fully-populated value, with `order` and `collapsed`
REQUIRED, while this one is `z.input` of the spec's `GroupingConfig`, where
both carry defaults and are therefore optional. Collapsing them would make one
of the two lie.
Renamed to `UsableGroupingField` — free repo-wide, and it says what it is: an
entry that passed `usableGroupingFields`. The components interface is
untouched; it keeps the name it already owned. A comment at the declaration
records why the two names differ, so the next reader does not "unify" them.
No behaviour change — the rename is type-level only, and the grid pin is
unaffected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRRumy89BYdW9ogbcdHTho
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3166.0 KB3191.4 KB
Main entry chunk (gzip)142.7 KB350 KB
Entry fileindex-BshEx1BK.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.59KB117.40KB
core (index.js)5.80KB2.32KB
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)47.00KB12.97KB
plugin-charts (index.js)70.02KB19.44KB
plugin-chatbot (index.js)194.82KB46.02KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)250.63KB63.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.78KB32.58KB
plugin-gantt (index.js)166.93KB40.82KB
plugin-grid (index.js)209.10KB56.65KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.51KB27.67KB
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.15KB8.40KB
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-litant
os-litant marked this pull request as ready for review September 2, 2026 13:17
@os-litant
os-litant added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit e21308eSep 2, 2026
32 checks passed
@os-litant
os-litant deleted the claude/issue-7217-grouping-null-entry-guard branch September 2, 2026 13:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A null entry in grouping.fields[] throws a TypeError that takes down the whole grid, before any projection is built

2 participants

@os-litant@claude