Skip to content

Stamp the persisted column width as width, not size — restore saved widths on ObjectGrid's ungrouped path - #6616

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6457-persisted-column-width-key
Aug 27, 2026
Merged

Stamp the persisted column width as width, not size — restore saved widths on ObjectGrid's ungrouped path#6616
os-sales merged 1 commit into
mainfrom
claude/issue-6457-persisted-column-width-key

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#6457

Resize a grid column, reload the page: on the ungrouped path the width was not restored. It was persisted correctly, reported to the host correctly, and read back correctly — then stamped onto the column under a key nothing consumes. The persistedColumns map now stamps width.

Re-measured on today's main — the card body is pre-#6461 and was not implemented as written

The card's line numbers and its (col as any) framing describe the file before PR #6461 landed. Every claim was re-taken by name at base 5ef9c4f5ff266380875b83b5b0e31f5cfe89e3ae:

claimwhere it is todayverdict
TableColumn declares width, not sizepackages/types/src/data-display.ts:313HOLDS — unchanged by #6461
ungrouped persistedColumns stamps sizeObjectGrid.tsx:2476 (card said :2286)HOLDS — drifted 190 lines
grouped path stamps widthObjectGrid.tsx:3460 via groupedColumnWidths (:3419, card said :3383-3387)HOLDS — the control still works
data-table reads no column-level sizepackages/components/src/renderers/complex/data-table.tsxHOLDS — 0 reads; the 9 .size hits are all Set/Map
width resolved at four sites as `columnWidths[accessorKey]col.width
setColumnWidths has one call site:1317, the resize handlerHOLDS
columnWidths never seeded from the schema:862, initialised emptyHOLDS
ObjectGrid never passes a columnWidths prop downObjectGrid.tsxHOLDS — 0 occurrences

So the round trip completed and was discarded at the last hop, and the column silently fell back to data-table's char-estimate auto width.

The grouped path is the control, and it is what picks the fix.groupedColumnWidths reads the samecolumnState.widths and has always stamped width — one path out of step with its sibling, not a missing convention. That is why the change is width here rather than a second spelling taught to data-table. packages/types/src/data-display.ts is not edited: the consumer's declaration was the correct one, and the producer is what was wrong.

Precedence is unchanged and needed no change: a persisted width still loses to an in-session resize and still beats auto-sizing.

The cast route — measured for this card rather than inherited

The retracted claim on the card is correctly retracted, and this PR does not rest on it. What was measured here, on its own, is narrower and specific to this seam: the stamp ran inside persistedColumns.map((col: any) =>, and that any is exactly what let the wrong key cross a boundary which has declared the right one since #6004. The callback is now typed ObjectGridColumn.

Measurement, both directions, on the committed tree:

  • typed callback + width (as shipped): pnpm --filter @object-ui/plugin-grid type-check — clean, exit 0.
  • typed callback + size (mutated, then restored): src/ObjectGrid.tsx(2496,26): error TS2353: Object literal may only specify known properties, and 'size' does not exist in type 'ObjectGridColumn'. / Exit status 2.

This is a bounded in-place fix inside the dispatched fence (the same map, same defect class, same gate family), declared here as required: it converts this defect class from a silent user-visible drop into a compile error, so it cannot return by hand. The seven surviving (col as any).reads elsewhere in the body are untouched — they are not this card's subject and are not addressed here.

The test is the deliverable — the INBOUND half

New: packages/plugin-grid/src/__tests__/columnWidthInbound-6457.test.tsx (3 cases).

The pre-existing columnStatePersistence.test.tsx pins only the outbound half, and its own docblock says it "deliberately observe[s] the WRITE, never the read-back". That is the measured reason this shipped: an outbound assertion passes on the broken code, because the write is exactly what was wrong. Every case here therefore starts from an already-persisted value and ends at the rendered column — style.width on the header cell in the DOM, the last hop the key has to survive. Nothing here observes a write.

  1. a width persisted in localStorage reaches the rendered column — and only that column, so a blanket width cannot satisfy it;
  2. a width handed in by the host through columnState reaches the rendered column (the ObjectView / updateViewConfig channel);
  3. grouped mode honours the same seed — the sibling path pinned as a control, so the two cannot drift apart again in either direction.

Seeds are 321 and 277 by construction: data-table's auto-size only ever yields min(400, max(80, maxLen*8+48)), and neither 273 nor 229 is divisible by 8, so no fallback value can satisfy these assertions.

Reverse verification (fix committed first, mutation confirmed on disk by anchored grep counts and a blob-hash comparison, restore proven by an empty git diff HEAD and a hash match against the HEAD blob; scripts carried trap … EXIT INT TERM with absolute paths). Predicted direction before running: cases 1 and 2 red, case 3 green because the grouped path reads columnState.widths directly and is untouched by the mutation. Observed exactly that:

× applies a width persisted in localStorage to the rendered column
× applies a width handed in by the host via columnState to the rendered column
AssertionError: expected [ '88px' ] to deeply equal [ '321px' ]
Test Files 1 failed (1)
Tests 2 failed | 1 passed (3)

88px is data-table's char estimate for that column (5*8+48) — the observed fallback, i.e. the user-visible symptom reproduced in the assertion.

Verification — all at 5a24c45c, the final commit

  • pnpm exec vitest run packages/plugin-grid/Test Files 95 passed (95), Tests 876 passed (876)
  • pnpm --filter @object-ui/plugin-grid type-check — exit 0. Confirmed measuring, not merely passing: tsc -p tsconfig.test.json --listFiles includes both ObjectGrid.tsx and the new test file, so the green covers the new test.
  • pnpm --filter @object-ui/plugin-grid lint679 problems (0 errors, 679 warnings), the package's standing baseline. The 2 warnings on the new file are no-explicit-any on the shared test-harness shape every sibling suite in that directory uses.
  • pnpm check:control-bytesOK (scanned 5471 tracked text file(s)); plus a direct control-character scan of the diff, no hits.
  • node scripts/check-changeset-presence.mjs — declares 1 changeset; node scripts/check-changeset-no-major.mjs — no major.
  • pnpm check:phantom-deps, check:self-import, check:vi-mock-specifiers, check:i18n-keys — all green.

Repo-wide pnpm lint and the full farm are left to CI, which runs them on this ref regardless.

Changeset: .changeset/6457-persisted-column-width-key.md, @object-ui/plugin-grid: patch.

Not in scope


Generated by Claude Code

The ungrouped `persistedColumns` map wrote the restored width onto the column
as `size`. Nothing downstream reads a column-level `size`: `TableColumn`
declares `width`, and `data-table` resolves a column's width at all four of its
sites as `columnWidths[accessorKey] || col.width || autoSizedWidths[key]`.
The user's resize was persisted, reported to the host and read back correctly,
then discarded at the last hop — so the width was never restored on reload and
the column fell back to the char-estimate auto width.
The grouped path in the same component reads the same `columnState.widths` and
has always stamped `width`; it worked. That asymmetry is what identifies
`width` as the fix rather than a second key taught to `data-table`.
`TableColumn` is not touched — the consumer's declaration is the correct one.
The map callback is typed as `ObjectGridColumn` instead of `(col: any)`, so a
stray `size` here is now a compile error: the cast is what let the wrong key
cross a boundary that has declared the right one since objectui#6004.
New pin covers the INBOUND half — a persisted width seeded through both the
localStorage and the host `columnState` channel, asserted at the rendered
header cell, plus the grouped path as a control. The pre-existing suite pins
only the outbound half, which passes on the broken code because the write is
exactly what was wrong.
Card: objectui#6457
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

MetricValueBudget
Eager closure (gzip, 52 chunks)3236.3 KB3266.6 KB
Main entry chunk (gzip)157.1 KB350 KB
Entry fileindex-BZcJ6F4w.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)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
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)506.90KB114.81KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
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.95KB10.97KB
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)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
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)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.98KB32.23KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)85.87KB21.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)65.97KB21.98KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
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)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)9.30KB3.22KB
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-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on substance (ui execution seat, session_01CRJge11jso9TpXRWFt1Z49). Landing is gated only on the farm finishing — 29/29 registered, 0 failures, test shards and Type Check still running at time of writing. ⛔ Not flipped yet: entry qualification is every check green on the reviewed head, not the required subset.

Reviewed at head 5a24c45c.

Why the fix direction is right, and not merely plausible

The load-bearing move is using the grouped path as a control. groupedColumnWidths reads the samecolumnState.widths and has always stamped width — so this is one path out of step with its sibling, not a missing convention. That is what makes width the correct fix rather than teaching data-table a second spelling, and it is why packages/types/src/data-display.ts is correctly not edited: the consumer's declaration was right and the producer was wrong.

⭐ Worth naming because it is the opposite disposition from sibling #6424, decided on the same day in the same family: there the consumer's declaration was the thing out of step and the fix was to declare. A seat that applies one habit to both gets one of them wrong. The control is what tells them apart.

The anti-vacuity work is the part I would have pushed back on, and it is already done

The pre-existing columnStatePersistence.test.tsx pins only the outbound half — and its own docblock says so. ⭐ An outbound assertion passes on the broken code, because the write is exactly what was wrong. That is the measured reason this defect shipped past a test suite that looks like it covers the feature, and it is the most reusable finding in this PR.

Every new case therefore starts from an already-persisted value and ends at the renderedstyle.width on the header cell — the last hop the key has to survive. Nothing observes a write.

Two further guards I checked rather than took on trust:

  • The seeds cannot be satisfied by the fallback.data-table's auto-size only yields min(400, max(80, maxLen*8+48)); for 321 or 277 that would need maxLen*8 to be 273 or 229, neither divisible by 8. So a blanket or auto width cannot make these assertions pass.
  • The grouped case is a control in the pin itself, predicted green because the mutation cannot reach it — so the two paths cannot silently drift apart again in either direction.

Reverse verification predicted the direction before running (cases 1 and 2 red, case 3 green), and observed exactly that, with the mutation proven on disk by anchored counts plus a blob-hash difference and the restore proven by an empty git diff HEAD plus a hash match — not by exit code. The observed 88px is data-table's own char estimate (5*8+48), i.e. the user-visible symptom reproduced inside the assertion. That is the standard.

The in-fence typing is accepted, and why it is not scope creep

Typing the persistedColumns callback as ObjectGridColumn is the same map, the same defect class, and it was declared rather than slipped in. It converts a silent user-visible drop into TS2353, so the defect cannot return by hand. The seven surviving (col as any)reads elsewhere in the body are correctly untouched.

⚠️ One cross-card note for whoever picks up this family: review of sibling PR #6615 measured that the two (col as any).fitContent casts in data-table.tsx are already redundant, because that file's own col: any normalization widens everything before the casts are reached. ⛔ So cast-count is not a reliable instrument for this family — the normalization is. That does not affect anything here (different file, and this PR removes a producer any rather than reasoning from a consumer cast), but a card in this family that reasons from cast-count is reading the wrong signal.

Housekeeping

Fixes #6457 is correct — the card is fully disposed of inside the fence. The navigation-overlay width/size seam (#6303 / #6259 / #6584) and #6424's deferred emit-side half near ObjectGrid.tsx:3418 are correctly named as out of scope and untouched.

⚠️ Noted, not blocking: the dev's self-check found #6457's body carries a mangled quote of the columnStatePersistence docblock — words dropped at both ends, which is why it reads as an incoherent fragment. Not sanitizer damage, and the conclusion it was quoted for is unaffected. Flagging it because quoted evidence in this family's card bodies is demonstrably not verbatim.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 27, 2026 14:37
@os-sales
os-sales added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit c4987fbAug 27, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6457-persisted-column-width-key branch August 27, 2026 14:50
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.

ObjectGrid writes the persisted column width as size, a key data-table never reads — saved widths are dropped on the ungrouped path

2 participants

@os-sales@claude