Skip to content

fix(grid): let the object-schema policy outrank row-key column derivation - #6722

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6677-grid-default-columns-branch-order
Aug 29, 2026
Merged

fix(grid): let the object-schema policy outrank row-key column derivation#6722
os-sales merged 1 commit into
mainfrom
claude/issue-6677-grid-default-columns-branch-order

Conversation

@claude

@claude claude Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #6677

ObjectGrid.generateColumns() checks three default paths in order: authored
columns → the inline-data path → the object-schema path. The inline-data path
is gated on hasInlineData (dataConfig.provider === 'value'), and dataConfig
is built as provider: 'value' from the data prop before anything else —
so it is taken by every grid whose rows were handed down rather than fetched. It
returned unconditionally whenever rows were present, and its projection is
schemaFields || Object.keys(inlineData[0]), the first row's keys.

That made the object-schema path — the one carrying the documented policy
(highlightFields first; else drop hidden, drop readonly system-managed, push
the remaining system/ownership columns to the end) — unreachable for every
object-bound grid reached through a fetching host
(ListView, ObjectView,
…). The branch that knows the object was the one that never ran.

Re-derived from code on origin/main @ 98188c284 rather than from the card's
line references, and reproduced as a test before any source edit.

The change

One gate, in packages/plugin-grid/src/ObjectGrid.tsx:

const rowKeysWouldOutrankSchemaPolicy = !schemaFields && !!objectName && !!objectSchema;
if (hasInlineData && !rowKeysWouldOutrankSchemaPolicy) {

The legacy path is reordered, not deleted, and the yield is as narrow as the
defect: only the row-key fallback is wrong for an object-bound grid, so only
that is given up, and only once there is a policy to give it up to. Plus the
comment block explaining why, and objectName named explicitly in the hook's
dependency array (it is derived from dataConfig + schema.objectName, both
already listed, so this adds no invalidation).

Why not gate on objectName alone — the transition the order asked about

objectSchema is useState(null) filled by an async fetch, so it is null on
first paint even when objectName is set. Gating the legacy path on objectName
alone falls straight through to if (!objectSchema) return [] and paints an
empty header row, then flips to five columns — a worse defect than the one
being fixed. Requiring the loaded schema keeps the row-key columns on screen
until the object is actually known.

Measured, not reasoned: with getObjectSchema held on a pending promise the
grid renders the ten humanised row-key columns and zero is never observed; when
the promise resolves it becomes the five policy columns. Both directions are
pinned. The same condition is the graceful fallback when the schema fetch fails
or the data source has no getObjectSchema — the existing catch already
promises "columns will still fall back to heuristic inference", and now that
stays true instead of the grid going blank.

Why an authored fields projection still wins

The schema path drops a name the object does not declare (if (!field) return;),
and a host may legitimately join or derive keys. !schemaFields is exactly the
condition under which the || reaches for the row keys, so the gate cannot drift
from the fallback it guards. Pinned with a fields: ['name', 'computed_score']
case where computed_score is not an object field.

Anti-vacuity

packages/plugin-grid/src/__tests__/hostFetchedDefaultColumns-6677.test.tsx
reproduces the card's measurement: the opportunity object with id marked
hidden: true and the four audit fields system + readonly, rows handed in
through the data prop exactly as a fetching host passes them.

RED-FIRST on the merge-base 98188c284, this file only — 3 red / 4 green:

× renders the schema policy's five columns, not the payload's ten
    AssertionError: expected [ 'Id', 'Opportunity Name', …(8) ]
      to deeply equal [ 'Opportunity Name', 'Stage', …(3) ]
× never appends the payload-only keys once the schema has loaded
    AssertionError: expected [ 'Id', 'Opportunity Name', …(8) ]
      to have a length of 5 but got 10
× TRANSITION: the policy takes over once the schema resolves
    AssertionError: expected [ 'Id', 'Opportunity Name', …(8) ]
      to deeply equal [ 'Opportunity Name', 'Stage', …(3) ]

The extras are appended to a superset, not substituted for the five, so the
test asserts the absence of Id / Created At / Created By / Updated At
/ Updated By as well as the presence of the right five — a presence-only check
passes on the broken build. The four greens are boundaries the reorder must not
cross and are green in both worlds: inline data with no object behind it, an
authored projection naming an audit column on purpose, an authored projection
naming a non-schema key, and the schema-in-flight first paint.

After the fix: 7/7 green in that file.

(col as any).fitContent in the grouped-width pass is untouched, and
columnReadBoundary-6458.test.ts is untouched — its control is not re-pointed
into the region this PR changes. packages/plugin-list is untouched.

Verification

Union re-run on this PR's final commit efbd027cb:

run result
pnpm exec vitest run packages/plugin-grid/ 98 files, 897 passed
pnpm exec vitest run packages/app-shell/ 570 files, 5606 passed, 1 skipped
pnpm --filter @object-ui/plugin-grid run type-check exit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
npx eslint . in packages/plugin-grid 135 files, 0 errors
pnpm check:control-bytes OK (5572 tracked text files)
pnpm check:vi-mock-specifiers OK
node scripts/check-changeset-presence.mjs OK — 2 published source files changed, 1 changeset
node scripts/check-changeset-no-major.mjs OK
node scripts/check-lint-coverage.mjs 46/46 packages linted, 0 with outstanding errors

Downstream consumers plugin-list + plugin-view + plugin-designer ran green
(92 files, 1069 passed) on a tree byte-identical to efbd027cb under
packages/** — only the .changeset/*.md was added after that run.

The type-check claim is measured, not assumed: tsc -p tsconfig.test.json --listFiles lists both edited files (ObjectGrid.tsx and the new test), so the
green covers them rather than excluding the test file.

Declared narrowing — lint

Repo-wide pnpm lint (turbo run lint) was not run locally; CI runs the
farm regardless. The narrowing is to packages/plugin-grid, with the evidence
that makes it a measurement rather than a gap:

  1. Population from ESLint's own config, not from a guess about which files
    count — eslint . inside the package resolves the root flat config's
    ignores and files: ['**/*.{ts,tsx}'].
  2. File count read from --format json: 135 files inspected, 0 errors.
  3. Config invariance for untouched files: eslint.config.js sets
    languageOptions to ecmaVersion + globals only — there is no
    parserOptions.project / projectService, so type-aware linting is not
    enabled and no rule reads across file boundaries. This diff therefore cannot
    move the verdict of any file it does not touch, and packages/plugin-grid is
    the only package whose .ts/.tsx inputs changed.

react-hooks/exhaustive-deps still warns at the edited hook (line 2423) about
navigation and schema.showColumnTypeIcons — pre-existing, and adding a
dependency cannot create a missing-dependency report for other identifiers.
ESLint fails only on errors here (no --max-warnings, deliberately), and errors
are 0.

Changeset

patch on @object-ui/plugin-grid, scored deliberately. No public API moves —
no prop, type, export or signature change — and this restores the
default-columns policy the component already documents and already applied
whenever the grid fetched its own rows; the host-fed divergence was the defect,
not a contract. minor was considered, because the visible column set changes on
existing screens, and rejected: the lost columns were never declared by any
author, only leaked by the branch order, and this repo scores behaviour-
correcting fixes as patch and reserves minor for new capability. A major is
never authored here — the fixed group tracks @objectstack.


Generated by Claude Code

…tion

`ObjectGrid.generateColumns()` checked the inline-data path before the
object-schema path. That path is gated on `hasInlineData`
(`dataConfig.provider === 'value'`), and `dataConfig` is built as
`provider: 'value'` from the `data` prop before anything else, so it is
taken by every grid whose rows were handed down rather than fetched. It
returned unconditionally whenever rows were present, and its projection
is `schemaFields || Object.keys(inlineData[0])` — the first row's keys.

The object-schema path, which carries the documented default-columns
policy, was therefore unreachable for every object-bound grid reached
through a fetching host. Measured on one page/source/object with one
variable: grid-fetches rendered 5 policy columns, host-fetches rendered
10, adding `id` (`hidden: true`) and the four `system` audit columns.

Only the row-key fallback yields, and only to a loaded policy
(`!schemaFields && !!objectName && !!objectSchema`). An authored `fields`
projection still wins, because the schema path drops names the object
does not declare and a host may join or derive keys. Requiring the
*loaded* schema avoids a worse defect: `objectSchema` is null until an
async fetch lands, so gating on `objectName` alone would paint an empty
header row on first paint and then flip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 49 chunks) 3232.8 KB 3266.6 KB
Main entry chunk (gzip) 157.3 KB 350 KB
Entry file index-DFYXXtG8.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 510.58KB 116.01KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 239.20KB 60.12KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.89KB 12.91KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.44KB 34.48KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.29KB 62.39KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.01KB 32.23KB
plugin-gantt (index.js) 165.20KB 40.37KB
plugin-grid (index.js) 201.53KB 54.54KB
plugin-kanban (index.js) 53.11KB 14.62KB
plugin-list (index.js) 113.01KB 27.57KB
plugin-map (index.js) 20.17KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 26.44KB 7.59KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 67.73KB 22.54KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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

Projects

None yet

2 participants