Skip to content

fix(components): size an auto-width column from what its cells draw, and stack right-pinned columns (objectui#11682) - #11725

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-11682-auto-width-formatted-text
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-11682-auto-width-formatted-text

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11682
Clause-②: no

The data table sized an auto-width column from the length of its STORED value. It now sizes it from what the column's header and cells DREW, read back from the rendered table. Two right-pinned columns also no longer stick to the same edge. All of it lives in packages/components/src/renderers/complex/data-table.tsx: no producer change, no new column, view or schema key, and no second copy of a formatter.

What was measured before building (the order's mechanism assumptions)

Live reading, one time: the showcase backend (objectstack main at aa09db58, objectstack dev --seed-admin --fresh), the console dev server of this branch's base 9ca3cac pointed at it, Chromium 1194, viewport 1440x900, the seeded admin. At 1440px the list's content box is 1184px wide (the sidebar takes 256px).

  1. Budget. budget is Field.currency on showcase_project. A stored 150000 is drawn 150,000.00 (no tenant currency is set, so it is a plain grouped number). The estimate read String(200000), which is 6 characters, so the column was 96px with a 72px content box. The widest amount needs 72.61px, so 3 of the 5 Budget cells truncated (scrollWidth 73 against clientWidth 72). The same 3 cells truncated on the CRM Workbench page. Confirmed.
  2. Where the drawn text comes from. The table does not hold the formatter. A grid column's cell is a React renderer from @object-ui/fields (CurrencyCellRenderer calls formatCurrency with the tenant currency and display locale from its own hooks). @object-ui/components cannot import that package, and col.type alone carries no currency, scale or storage. The cheapest faithful source is the DOM those renderers drew, so that is what the estimate reads now. The table calls no formatter of its own for it.
  3. Progress under Actions. progress on showcase_task is type: 'progress' (not percent), drawn by the percent cell renderer as a 64px bar beside the number. It is stored as whole points (45), not as 0.45 as the card says. Neither of the two causes the order named was the cause. The Progress estimate was not too small: its cell was not clipped (88 of 88px). Nor was a reserve missing for the pinned column. The other columns were too WIDE: Assignee got 240px for 171px of drawn text, Title 200px for 120px, Project 168px for 107px (a lookup arrives as an expanded object, and String() of it is 15 characters). The columns summed to 1230px in the 1184px box, so the scrolling region ran 46px under the right-pinned Actions column at scroll 0. That is where the right-aligned percentage sits: every Progress cell was 33px under Actions. The pinned column is not shrunk.
  4. An intermediate that did not hold. Estimating from the drawn text's LENGTH (the old 8px a character) fixes Budget (128px), but the Tasks table grew to 1238px and Progress went 41px under Actions. That is why the width is now MEASURED where the page is laid out.

What changed

  • Auto width from the drawn content. Each auto-sized column's header label and display cells carry data-auto-width-key. A layout effect reads them back after every commit, before paint. Where the page is laid out, the column gets its widest target's natural width (at max-content) plus the space the cell keeps around its content. That space is measured (cell width less content box), not read from the computed padding. Under the table's collapsed borders, the column beside the frozen first column, and the one beside Actions, carry half of a neighbour's rule, which no computed style reports. With computed padding, the CRM Workbench's Status column came out 1px short and its "Completed" badge truncated. Where nothing is laid out (happy-dom, a table inside a hidden panel), the drawn text's length is the input to the old estimate. The stored value is read only before the first draw. The 80px floor and the 400px cap stay. A masked column is still sized from its header alone (objectui#10657). An explicit width, a fitContent column and a dragged width are unchanged.
  • When it re-reads. The read is skipped when nothing drawn has changed: a signature of the targets' text and their cells' classes (density, a staged edit's weight). It runs again on a body mutation (a lookup cell resolves its name inside its own component, after the table rendered), on a table resize (a table shown from a hidden panel) and once the fonts have loaded. Only text and node changes are observed, and the state is replaced only when a size moved, so a read cannot feed itself. It is skipped while a cell is being edited.
  • Stacked right pins. Every right-pinned column carried right-0. So a column an author pins right (pinned: 'right', which ObjectGrid honours) beside the auto-pinned Actions column stuck to the same edge and was drawn under it. Measured in a harness at a 700px container: Due Date sat at [880..1000] under Actions at [933..1000]. Each right-pinned header and body cell now sticks at the measured width of the pinned cells after it, the mirror of measuredStickyLefts. After the change, Due Date sits at [538..633] beside Actions at [633..700]. A table with one right pin renders as before.

Readings after the change (live, same setup, the tree at 3341132)

Page Table / content box Budget Progress Clipped cells Cells under the last column at scroll 0
Projects, All Projects 1326 → 1184 / 1184 96px (truncated) → 107px, content 83px 3 → 0 10 → 0
Tasks, All Tasks 1230 → 1184 / 1184 112px, 33px under Actions → 152px, whole 0 → 0 10 → 0
CRM Workbench 760 → 651, its 3fr grid track 96px (truncated) → 97px, content 73px 3 → 0 0 → 0

The scan checks every body cell and its descendants for scrollWidth above clientWidth, and every cell's drawn content for reaching past the left edge of the row's last cell.

Pins: which are arithmetic and which replay geometry

data-table-drawn-width-11682.test.tsx. happy-dom lays nothing out.

  • Arithmetic (the text path): a currency cell drawing 200,000.00 gets 128px (CONTROL: the same column drawing its stored value gets 96px). A lookup drawing Mobile App for a 16-character id gets 128px (it was 176px). A cell whose text arrives after the table rendered re-sizes the column (256px), through the body observer alone.
  • Replayed geometry: a fake layout answers only the probe (an auto-width target at max-content), with the natural widths measured in Chromium on the two showcase lists. It replays the measured insets (24px, 25px beside the frozen rule, 24.5px beside Actions). A table that does not probe falls back to the text path, and the pins go red. Budget: the content box holds 72.61px. Tasks: the columns plus the measured fixed columns (checkbox, #, Actions) fit 1184px, and every column holds its widest drawn cell and label, Progress's 109.36px included. The probe leaves no inline width behind.
  • Mocked header widths: two right pins, so the inner one sticks at the outer one's 67px and the outer one stays at right-0. CONTROL: one right pin renders as before.
  • Rewritten control in data-table-masked-client-paths-10657.test.tsx: unflagged, the column is now sized from the mask its cells draw, not from the raw value. Flagged, its cells are not read at all, which still changes the width. The old control pinned exactly the stored-value branch this change removes.

The live readings above are the geometric half. They are one time and are not committed as a test: the repo's Playwright suite serves a production console build with no backend, so it cannot reach these lists.

Ablation (one leg, from the committed fix)

node ../objectstack/scripts/ablation-replace.mjs replaced setDrawnSizes(next); with void next;, so the read-back never reaches the estimate and every column falls back to the stored value. The mutation was confirmed on disk (anchor 1 → 0, marker count 1 inside the run, blob 9b34e266 → 08662811). Predicted: the 3 text pins, Budget, Tasks and the masked control red. Observed: exactly those 6 red, 12 green.

  • expected '96px' to be '128px', expected '176px' to be '128px', expected '104px' to be '256px'
  • the content box holds the widest drawn amount: expected 72 to be greater than or equal to 72.61
  • the columns sum to 1229.88px: expected 1229.88 to be less than or equal to 1184
  • the drawn mask, not the raw value, sizes the column: expected '304px' to be '80px'

Restored by the tool's own trap. Restore proof: blob after restore 9b34e266 equals the HEAD blob, and git diff HEAD is empty.

⚠️ Declared narrowing: this leg ran OUTSIDE the shared verify lock, at --maxWorkers=1. Three locked attempts timed out in the queue (exit 99, NOT MEASURED). In each, the mutation landed and was restored. The lock was held for more than 30 minutes by another seat's full packages/app-shell/ suite. The run was these two test files only.

Verification (head 3341132)

  • pnpm exec vitest run packages/components/ packages/plugin-grid/ --maxWorkers=2 (locked): Test Files 547 passed | 1 skipped (548), Tests 5403 passed | 24 skipped (5427).
  • pnpm --filter @object-ui/components build (locked, dependency closure built first): exit 0.
  • @object-ui/components type-check (both projects; tsconfig.test.json includes src/**/*.test.tsx): exit 0.
  • @object-ui/components lint: 0 errors. The three touched files carry the same 35 warnings as the base copies of the two files that already existed. The package as a whole has 972 warnings, none of them errors.
  • Exit 0: check:new-line-citations (0 new), check:control-bytes, check:changeset-claims, check:pending-changeset-literals, changeset:check (fixed group, no major), scripts/check-changeset-presence.mjs, scripts/check-governed-queue-guard.mjs --test over the four paths (NOT GOVERNED), and also check:test-path-roots, check:spec-symbols, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:handler-key-reads, check:comment-mask-corpus, check:unreferenced-sources.
  • The repo-wide pnpm lint and the rest of the check:* farm are left to CI.

Acceptance notes

  • The width follows the rows that are drawn. On a table that pages, searches or sorts its own rows (inline data), a column can change width with the page, the search or the sort. The console's object lists page and search on the server, so they were re-estimated per page before this change too.
  • A table wider than its container still scrolls under its pinned column. At scroll 0, the right-pinned column overlays the scrolling columns that pass beneath it. That is what pinning is, and it is not changed here. At the scroll end nothing is covered (measured at a 700px container, Progress ends at 539px and the pinned cells begin at 538px, on their 1px rule). This change makes the measured lists fit, and makes pinned columns stop covering each other. The literal reading of "never covers" for an overflowing table is in the report.
  • The checkbox and # columns now take more of the spare width (Tasks: 28 → 45px and 39 → 45px), because the auto columns are tighter. That is the browser's share-out of w-full, not a width this table sets.
  • Observation, not filed: Projects' Budget Remaining (a formula over two currency fields) draws an ungrouped monospace 90000.
  • Cost: one layout effect per commit builds the signature. The two forced layouts happen only when it changes, or on a body mutation, a table resize or the fonts loading.

Generated by Claude Code

claude added 4 commits October 6, 2026 14:01
…and stack right-pinned columns

The data table estimated an auto-sized column from the stored value's
length. A currency cell storing 200000 draws "200,000.00" and truncated;
a lookup storing an id draws a name and was too wide, and the columns'
sum pushed the scrolling region under the right-pinned Actions column.

The table now reads back what each auto-sized column's header and cells
drew: the natural width of that content where the page is laid out, its
text length where it is not, and the stored value only before the first
draw. Two right-pinned columns no longer share right-0: each sticks at
the measured width of the pinned cells after it.

Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju
Co-authored-by: Claude <noreply@anthropic.com>
…fect

The latest-function ref was assigned during render, which the
react-hooks refs rule flags. The assignment now happens in the same
deps-less layout effect that calls it, before the observers' passive
effect can. The new test types the registry lookup instead of casting.

Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju
Co-authored-by: Claude <noreply@anthropic.com>
…stead of reading its padding

Under the table's collapsed borders a cell beside the frozen column's
2px rule, or beside the Actions column's 1px rule, carries half of that
rule, and no computed style of its own reports it. Read off the computed
padding, the CRM Workbench's Status column came out 1px short and its
"Completed" badge truncated. The read now takes each cell's width less
its content box's, in the same layout that reads the boxes, and the
replayed-geometry pins carry the measured insets.

Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 331 chunks) 3324.8 KB 3330.4 KB
Main entry chunk (gzip) 153.6 KB 350 KB
Entry file index-BnlNunv1.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) 17.82KB 6.58KB
app-shell (runtime-config.js) 22.52KB 7.86KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
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) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 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) 578.98KB 139.21KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 233.72KB 64.83KB
fields (index.js) 262.75KB 66.62KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 35.66KB 9.49KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 41.18KB 11.71KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
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) 16.04KB 3.92KB
plugin-calendar (index.js) 53.39KB 15.52KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 198.81KB 47.14KB
plugin-dashboard (index.js) 143.75KB 38.87KB
plugin-designer (index.js) 231.46KB 48.87KB
plugin-detail (index.js) 247.25KB 65.05KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.09KB 45.89KB
plugin-gantt (index.js) 179.16KB 45.06KB
plugin-grid (index.js) 238.48KB 65.51KB
plugin-kanban (index.js) 52.17KB 16.37KB
plugin-list (index.js) 116.72KB 29.10KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 38.90KB 11.74KB
plugin-tree (index.js) 14.51KB 5.15KB
plugin-view (index.js) 90.23KB 22.73KB
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.81KB 3.58KB
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) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
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) 1.13KB 0.65KB
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) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
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) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 15:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 7a2c60b Oct 6, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11682-auto-width-formatted-text branch October 6, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant