Skip to content

fix(plugin-grid): one owner for ObjectGrid's cell-renderer resolution, so a format-hinted column keeps its renderer on every path - #9010

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-8920-grid-cell-renderer-resolution
Sep 10, 2026
Merged

fix(plugin-grid): one owner for ObjectGrid's cell-renderer resolution, so a format-hinted column keeps its renderer on every path#9010
os-steve merged 1 commit into
mainfrom
claude/issue-8920-grid-cell-renderer-resolution

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #8920

The defect, re-measured on today's tree

@object-ui/fields publishes a two-step resolve — getCellRenderer applied to the output of resolveCellRendererType — because a field's declared type is not always the renderer's key: a textual base type carrying a format hint (phone, email, url, currency, percent) maps to the richer renderer. Skip step two and the cell gets TextCellRenderer, which destructures only value and so can never read field.format. Nothing throws, nothing warns; the column just renders as plain truncated text where a tel: anchor belongs.

Every count below is my own, re-derived on origin/main e8b7b0785 with controls — the card's path:line anchors are a snapshot and were not cited.

Non-test getCellRenderer call sites under packages/*/src, excluding packages/fields itself: 15 lines across 10 files. Nine files hold exactly one each and all nine resolve first. ObjectGrid.tsx holds the other six, and they used three conventions:

site line at e8b7b0785 argument convention
A 2503 inferredType, from resolveCellRendererType({ type, format }) two-step
P 2639 the literal 'select' fixed registry key
B 2707 resolvedType = fieldDef?.type || inferColumnType(...) declared type raw
C 2864 resolvedType = fieldDef?.type || inferColumnType(...) declared type raw
D 2985 field.type declared type raw
E 4437 fieldDef.type declared type raw

Arithmetic closes: 9 files + A = 10 resolving, 4 raw, 1 fixed key = 15.

Instrument validated in both directions before any zero was quoted. A first pathspec (packages/*/src) returned 0 call sites — a git pathspec that matches no file path, not a reading. A known-present control token under the same pathspec also returned nothing, which is what exposed it; the corrected enumeration is the table above. And resolveCellRendererType is imported and called inside ObjectGrid.tsx itself, so "this site does not resolve" is a real reading rather than a broken probe. Independently, the boundary pin added here counts 6 getCellRenderer + 1 resolveCellRendererType call shapes in the pre-fix file when run against it — the same six, from a second instrument.

What changed: the shared resolution, not four call sites

Per the triage ruling, patching four sites to match one leaves the divergence in place. The resolution itself now lives in packages/plugin-grid/src/cellRendererResolution.ts, and ObjectGrid.tsx no longer imports either published resolver.

  • Five of six route through resolveGridCellRendering(field) — A, B, C, D, E. It returns three things: declaredType (which type the field has), rendererType (which renderer that type maps to, format applied), and a total Renderer.
  • One cannot, and it is named rather than left silent. The compound-cell prefix badge (P) asks for a fixed registry key: prefix.type === 'badge' is the author saying "draw the neighbouring column's value as a badge", and the key is a constant of this component, not anything the prefixed field declares. There is no declared type to promote and no format to read, so a helper keyed on "declared type + format hint" cannot own it without misrepresenting the call. It routes through the same module's second, documented entry point — gridCellRendererForFixedKey(BADGE_PREFIX_RENDERER_KEY) — so the grid has exactly two ways to reach a renderer, both in one file, neither of them a bare call.

The naming trap, closed

The card asked whether the name was part of the defect. It was. resolvedType held the answer to "resolve which type the field has" while reading exactly like the answer to "resolve which renderer the type maps to", so getCellRenderer(resolvedType) slid past review. Both answers are now named, and the two are used deliberately: rendererType drives the renderer, fieldMeta.type, the header icon and numeric alignment; declaredType is what the emit forwards as type: to the inline editor, so a text + format: 'phone' column still edits as text. Path A already made exactly that split; B, C and D now agree with it.

Convergence beyond the renderer, stated explicitly

Alignment and the header type icon read rendererType on every path now, as path A already did — a text + format: 'currency' column right-aligns like the currency it renders as. Path E's inner if (CellRenderer) guard is deleted: getCellRenderer ends in standardMap[key] || TextCellRenderer and never returns anything falsy, and the helper's return type now states that totality.

Acceptance: one assertion per path, each with its own control

packages/plugin-grid/src/__tests__/formatHintedColumnRenderer-8920.test.tsx renders a real ObjectGrid (happy-dom) once per path. A single case through one path would prove the helper works, not that the divergence is gone — the population is the subject, so each path gets a case.

The oracle is rendered output, because it has to be: getCellRenderer(field.type) type-checks, so every type-level and doc-level gate reads the broken sites green, and a green that cannot go red on this defect is not evidence.

The control column, note, is declared without a hint and holds a second, different phone number. So each case asserts three things: the hinted value became tel:+15551234567; the control value did not become tel:+15559876543; and the control's text is still on screen, so the absence half cannot be satisfied by the column having vanished. A control holding a non-phone string would only have shown the fix did not reach it; this one shows the promotion is driven by the declared format and not by the value's shape.

case path reached verdict
A columns as objects pin, green on both sides
B columns as strings red before
C inline-data projection (fields, no columns) red before
D object-schema default columns (rows handed down) red before
E record-detail panel (portal, read by test id) red before
P compound-cell prefix badge pin, green on both sides

A and P are must-not-change pins, not evidence: A is the site that already resolved, and P has nothing to promote. They are present because a shared helper that quietly changed either would be a regression this file has to catch.

packages/plugin-grid/src/__tests__/cellRendererResolutionBoundary-8920.test.ts adds the structural half — the rendering test cannot see a seventh site added tomorrow, which renders perfectly for every unhinted type. It bounds direct resolver calls under packages/plugin-grid/src to the helper module alone, with three anti-vacuity controls (population asserted non-trivial by count; matcher proved on synthetic input in both directions; matcher proved against a real file at real scale).

Ablation — predicted before running, and matched

Predicted 4 red / 2 green-both-sides on the render file. Measured, by restoring ObjectGrid.tsx to e8b7b0785 (the commit pinned at branch creation, never a moving remote-tracking name):

  • mutation proved on disk before reading any result — the file's blob hash moved from the fix's 55031aa3d to the base's 064b293dc, and the injected/removed text was counted in both directions (getCellRenderer( occurrences 0 to 6; resolveGridCellRendering( occurrences 6 to 0);
  • result: 6 failed / 5 passed across the two files — B, C, D, E red exactly as predicted, A and P green, plus both structural bounds red;
  • representative red: AssertionError: expected [] to include 'tel:+15551234567';
  • the boundary pin's red independently reported [ 'resolveCellRendererType', 'getCellRenderer' x 6 ];
  • restore proved by observation, not by exit code: git checkout HEAD -- on an absolute path, then git diff HEAD empty and the on-disk hash back to 55031aa3d. The script carried a trap ... EXIT INT TERM restore for the crash path.

No ablation artefact is left in the tree; the mutation is not resolved through any dist/, since the root vitest config aliases these packages to source (the tests ran green before any package was built).

Gates

gate exit note
turbo run type-check --filter=@object-ui/plugin-grid 0 14 tasks; tsc --noEmit plus tsc -p tsconfig.test.json, so the new tests are in the program
vitest run packages/plugin-grid/ (repo root) 0 123 files / 1082 tests — 121 test files at base plus the 2 added here
vitest run packages/plugin-list/ packages/plugin-view/ 0 111 files / 1212 tests — the two packages that render a real ObjectGrid
turbo run lint --filter=@object-ui/plugin-grid 0 797 warnings, 0 errors; the helper module contributes none, the render test contributes 3 no-explicit-any in the same as any idiom every suite in this package uses
node scripts/check-control-bytes.mjs 0 plus a manual sweep of the five touched paths
node scripts/check-changeset-presence.mjs 0 4 published source files changed, 1 changeset declared
node scripts/check-governed-queue-guard.mjs --test (5 paths) 0 NOT GOVERNED — none of the five paths matches a governed surface
pnpm check:doc-snippets 0 first run exit 2 = PRECONDITION NOT MET, not a pass; re-run after the scoped build it names (35 tasks). 638/638 blocks
pnpm check:doc-types 0 188 docs, 1105 blocks
pnpm check:doc-examples 0 124 blocks, 89 failures all declared in the ledger
pnpm check:new-line-citations 0 0 new citations

Heavy runs went through ../objectstack/scripts/pm/os-verify-lock.sh with a stable slot, per AGENTS.md.

Serial adjacency, re-derived here

Derived from the live open-PR list and each PR's own changed-file set against its merge base — no snapshot cited. No open pull request touches packages/plugin-grid/src/ or packages/fields/src/. #8941 and #7058 touch packages/plugin-grid/package.json, but only the lucide-react dependency range; #8996 is packages/core + packages/data-objectstack, #8998 is packages/create-plugin, #8999 is packages/plugin-dashboard, #8984 is packages/types + packages/plugin-detail. Each adds its own distinctly named changeset. Zero overlap with the five paths here.

Scope

Out of scope and untouched, as the card fences: packages/fields — both published helpers are correct as they stand; the nine already-correct call sites in the other five plugin packages; and the contract-review verdict on PR objectui#8915, one of whose to-clear items this executes. What moved is the six call sites in plugin-grid, plus the module that now owns them.

Acceptance notes

Observations recorded here rather than filed as cards — neither is a defect, and no queued pull request or person is heading for either file:

  • resolveCellRendererType promotes a field that declares no type but does carry a format, because the empty string is a deliberate member of its textual base set. resolveGridCellRendering declines to promote in that case and says why in its own doc comment: every grid path already treats "no declared type" as "no type-aware renderer", and reversing that is wider than this card rules on. Recorded as a bounded decision, not a defect.
  • The nine resolving call sites in the other five plugin packages each spell the two-step by hand with a slightly different tail (|| def.type, || 'text', || ftype || 'text'). All nine are correct today; this is a shape observation about a surface the card explicitly fences off, with no current successor. Noted, not filed.
  • With showColumnTypeIcons on, an untyped column gets the generic type icon on the object-columns path and no icon on the string-columns and inline-data paths — path A spreads headerIcon unguarded where B and C guard on the type. Pre-existing, cosmetic, unchanged by this pull request. Noted, not filed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB


Generated by Claude Code

…at hint -> renderer"

`@object-ui/fields` publishes a two-step resolve — `getCellRenderer(` applied
to `resolveCellRendererType(...)` — because a textual base type carrying a
`format` hint maps to a richer renderer than its declared type does. ObjectGrid
spelled that resolve six times with three conventions: one two-step, four raw
declared types, and one fixed registry key. On the four raw sites a `text` +
`format: 'phone'` column silently resolved to `TextCellRenderer`, which reads
only `value` — the hint was dropped with no error, no warning and no `tel:`
link, in the flagship grid.

Patching four sites to match one would have left the divergence in place, so
the resolution itself moves into `./cellRendererResolution`: five sites take
its field entry point, the compound-cell prefix badge takes its named
fixed-key entry point (a different contract, documented rather than left
silent), and ObjectGrid no longer imports either published resolver.

The module names the two answers the word "resolve" used to collide on —
`declaredType` (which type the field has) and `rendererType` (which renderer
that type maps to). A local called `resolvedType` holding only the first is
what made four broken sites read as correct.

Alignment and the header type icon now follow `rendererType` on every path, as
the object-columns path already did; the type forwarded to the inline editor
stays `declaredType`, so a hinted text column still edits as text.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3490.2 KB 3512.7 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-CSjnvYet.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) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
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) 11.08KB 4.58KB
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) 500.20KB 114.67KB
core (index.js) 7.95KB 3.19KB
create-plugin (index.js) 28.21KB 9.54KB
data-objectstack (index.js) 207.32KB 57.37KB
fields (index.js) 247.01KB 62.29KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.99KB 0.87KB
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 (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.63KB 19.99KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 130.98KB 34.54KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 253.71KB 65.78KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.65KB 40.91KB
plugin-grid (index.js) 211.36KB 57.45KB
plugin-kanban (index.js) 46.03KB 14.30KB
plugin-list (index.js) 112.52KB 27.64KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
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) 83.34KB 27.61KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
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) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
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 (expression.js) 0.20KB 0.18KB
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.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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