Skip to content

refactor(core): rowHeightToDensityMode abstains for an off-spec rowHeight, matching the spec bridge (#4440) - #4447

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4440-rowheight-one-answer
Aug 12, 2026
Merged

refactor(core): rowHeightToDensityMode abstains for an off-spec rowHeight, matching the spec bridge (#4440)#4447
yinlianghui merged 1 commit into
mainfrom
claude/issue-4440-rowheight-one-answer

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4440

Implements the delegated ruling on #4440: @object-ui/core's rowHeightToDensityMode stops coercing an unknown rowHeight to comfortable and gives the same no-mapping answer @object-ui/react's spec bridge has given since #4352 (PR #4439). One system, one answer. AGENTS.md #0.1 — one strict contract beats N dialects, and a bad rowHeight gets fixed at the producer.

1. Empirical check — run FIRST, as the ruling required

The card's own caution was that this fallback sits on a live render path, unlike #4352's unreachable keys. So the callers were enumerated and the rendered outcome measured on both sides before anything was edited.

Callers of rowHeightToDensityMode — the complete set

# Caller What it does with the answer
1 packages/plugin-list/src/ListView.tsx:1038 feeds useDensityMode, whose density.mode drives the toolbar button, the row padding/font, and the rowHeight forwarded to every child view

Exactly one in-repo caller (the card cited ListView.tsx:877; the line has moved, the caller has not). Plus out-of-tree hosts: packages/core/src/index.ts:72 re-exports the whole module, so this is a published surface.

Checked and not callers: app-shell/ObjectView.tsx imports DENSITY_MODE_TO_ROW_HEIGHT (the write direction only); plugin-grid/ObjectGrid.tsx reads schema.rowHeight directly and never routes through this function.

What can put an off-spec value into that caller

Producer Off-spec possible?
Stored view definitions — app-shell/ObjectView.tsx:1545 (viewDef.rowHeight ?? listSchema.rowHeight), plugin-view/ObjectView.tsx:1001 Yes in principle — an untyped DB boundary. This is the per-user-override path the card flagged.
Authored view metadata Only if authored off-spec — swept, none
Toolbar persistence, ObjectView.tsx:1572 No — Record< DensityMode, RowHeight > codomain
Legacy densityMode fold, normalize-list-view.ts:221 No — same typed table
ListView.tsx:1698, density forwarded to children No — computed as compact/medium/tall

Is any off-spec value actually authored? Sweep re-run at today's HEADs

Scope Result
objectui @ d7f3e308b — every ts/tsx/json/yml/yaml none. 57 rowHeight hits, all classified: i18n labels for the key name (9 locales), the boolean userActions.rowHeight flag, pixel-number props on unrelated widgets (gantt / VirtualGrid / dashboard), the typed tables, pass-throughs
objectstack @ 5d24f4b94 — showcase, CRM, packages/spec, platform-objects none. Every authored value is the boolean flag or one of compact / medium / short / tall
hotcrm not present in this container — inherited from PR #4439's sweep (lead.view.ts:182 authors rowHeight: 'medium')

Independently confirms PR #4439's three-repo sweep, at newer HEADs.

TODAY vs AFTER, measured on the live render path

Not argued from the mapping — rendered. packages/plugin-list/src/__tests__/ListView.density.offspec.test.tsx renders the real ListView and reads the density button's aria-label, which is density.mode verbatim.

schema.rowHeight TODAY AFTER Delta
absent Compact Compact
compact / short Compact Compact
medium Comfortable Comfortable
tall / extra_tall Spacious Spacious
comfortable, spacious, small, large, gargantuan Comfortable Compact changes
42 (non-string, from a stored view) Comfortable Compact changes
toString / constructor [Function toString] Compact changes — see §5

What the changed rows mean on screen: density comfortablecompact is row height 40px → 32px, padding py-2 px-3py-1 px-2, font text-smtext-xs (react/src/hooks/useDensityMode.ts:46-62), and the rowHeight forwarded to child views goes 'medium''compact'.

Reading: reachable code path, unreachable input class. The delta is real and is reported as the ruling asked, but no metadata in existence reaches it. It moves the caller onto the bridge's answer, which is the intended direction.

2. Where the abstain lands — the measurement that changed the implementation

undefined has to land somewhere, and the obvious landing is a trap.

  • Chosen: rowHeightToDensityMode(schema.rowHeight) ?? 'compact' — the consumer's own "nothing was said" default. It was already there in the branch this replaces (if (schema.rowHeight) … return 'compact'), and ObjectGrid independently picks the same one (schema.rowHeight ?? 'compact').
  • Rejected: letting undefined flow into useDensityMode. Its signature is initialMode: DensityModeValue = 'comfortable', so undefined compiles and lands on comfortable — rebuilding the exact coercion this PR retires, one frame lower, while every unit test of the mapping goes green. Reverse verification B below measures this: it is invisible to core's own pins and to the agreement pin, and only the render pin catches it.

3. Ruling condition 2 — the Record< RowHeight, … > typing

Already present, nothing added. normalize-list-view.ts:47 has declared ROW_HEIGHT_TO_DENSITY_MODE: Record< RowHeight, DensityMode > with RowHeight re-exported from @objectstack/spec/ui since objectstack#4115. The upstream-enum-growth build-failure property #4439 bought for the react twin was already held here. Measured, not assumed — and the honest answer to a condition can be "it is already true".

4. Ruling condition 3 — the agreement pin, and why it lives where it does

packages/react/src/spec-bridge/__tests__/RowHeightDensityAgreement.test.ts (new file).

Placement is decided by the dependency direction, and core cannot host it. @object-ui/react depends on @object-ui/core, so this package can see both surfaces; core importing react would invert the graph. The pin therefore imports core's function by its published specifier (@object-ui/core, a declared dependency — check:phantom-deps green) and reaches the bridge through SpecBridge.transformListView, whose parameter is any — the untyped boundary a host's stored JSON crosses, and since #4352 the only way an off-spec rowHeight can enter the bridge at all. A new file rather than an edit to SpecBridge.test.ts, so it cannot collide with in-flight work on that surface.

It asserts both surfaces agree for the five spec values (controls) and for the off-spec class, plus the invariant itself (core(x) === bridge(x) for every off-spec x) so a future fallback re-added to either side breaks it even if re-added to both differently.

One honest exclusion, stated in the file: Object.prototype member names are deliberately not in the pin. Core abstains for them now; the bridge still indexes its table with an unguarded key and hands back Object.prototype.toString as a density. That is a different defect in source outside this card's surface — filed as #4442, with the comment pointing there.

5. A latent defect the empirical check surfaced

The guard was rowHeight in ROW_HEIGHT_TO_DENSITY_MODE, and in walks the prototype chain. So rowHeightToDensityMode('toString') returned Object.prototype.toString — a function, from a signature that says DensityMode. Caught by a red-first case, not by reasoning:

AssertionError: expected [Function toString] to be undefined
- Expected: undefined
+ Received: [Function toString]

Now an own-property check, matching the repo convention (Object.prototype.hasOwnProperty.call, as in core/src/utils/freeze-schema.ts:135).

6. Red-first, captured against the unmodified source

All three pins were written and run before the source was touched:

 Test Files  3 failed (3)
      Tests  20 failed | 50 passed (70)

AssertionError: expected 'comfortable' to be undefined          (core unit, 8 cases)
AssertionError: expected 'comfortable' to be undefined          (agreement pin, 6 cases)
AssertionError: expected 'Comfortable' to be 'Compact'          (rendered, 6 cases)
AssertionError: expected [Function toString] to be undefined    (prototype key)

The 50 green are the controls: the five spec mappings on each of the three surfaces, plus absent-rowHeight. After the change: 70 passed (70).

7. Verification (local, all green)

  • Build closure first, per the fresh-worktree rule: pnpm --filter '@object-ui/plugin-list^...' build — exit 0, before any judging.
  • Repo-root vitest packages/core/ packages/react/ packages/plugin-list/154 files, 2772 tests passed.
  • Both tsc commands for all three packages — tsc --noEmit and tsc -p tsconfig.test.json, six runs, all exit 0.
  • Downstream consumer sweep, run AFTER rebuilding core so consumers read the NEW .d.ts, not a stale one: turbo run type-check --concurrency=2 repo-wide — 78 successful, 78 total. Repo-wide is a superset of the ...@object-ui/core prefix filter (downstream consumers), which is the direction a return-type widening breaks.
  • eslint on all five touched files — 0 errors. 168 warnings, every one pre-existing no-explicit-any / exhaustive-deps in ListView.tsx; none in the edited region (1033-1048), none in the three test files.
  • check:control-bytes — OK, 4148 tracked text files; plus a self-scan of the touched files across the wider control-byte range: no hits.
  • check:phantom-deps — OK. check:spec-symbols — OK.
  • check-changeset-presence — 1 changeset declared. check-changeset-no-major — OK.

8. Reverse verification (both directions predicted before running)

Taken out with git checkout origin/main -- FILE, restored with git checkout HEAD -- FILE — never git stash.

A. Restore the coercion in core → the pins red. Predicted the normal direction (the assertions read a value the restored branch produces, not a count a gate reports). Measured 21 failed | 49 passed, on all three surfaces:

 × gives no density for the off-spec rowHeight "comfortable"      (core unit)
 × neither surface invents a density for the off-spec rowHeight … (agreement)
 × renders the off-spec rowHeight gargantuan as Compact           (rendered)
AssertionError: expected 'comfortable' to be undefined
AssertionError: expected 'Comfortable' to be 'Compact'

21, not the 20 of the red-first run, and the extra one is worth naming rather than rounding off: renders an absent rowHeight as Compact also fails here. This state is a mixture — old core, new caller — and the old function coerced undefined to comfortable too, which is precisely why the caller needed its if (schema.rowHeight) guard. That the guard becomes unnecessary once the function can abstain is evidence for the change, not a defect in the pin.

B. Restore only the CALLER, keeping core fixed → only the render pins red. Predicted: core's unit pins and the agreement pin stay green (the mapping is correct), the six off-spec render cases go red because undefined reaches useDensityMode's = 'comfortable' default, and the absent-rowHeight control stays green (the old caller's explicit return 'compact'). Measured exactly that — 6 failed | 64 passed, one file red:

 × renders the off-spec rowHeight comfortable as Compact
 × renders the off-spec rowHeight spacious as Compact
 × renders the off-spec rowHeight small as Compact
 × renders the off-spec rowHeight large as Compact
 × renders the off-spec rowHeight gargantuan as Compact
 × renders a non-string rowHeight as Compact
AssertionError: expected 'Comfortable' to be 'Compact'

This is the measurement that makes the render pin load-bearing rather than a duplicate of the unit pin: the §2 trap is green on every mapping-level assertion and red only here.

Controls green in both directions, and after restoring: git status clean, git diff HEAD empty, packages/core/ packages/react/ packages/plugin-list/ back to 154 files / 2772 tests passed.

9. Changeset grading — measured

.changeset/rowheight-density-one-answer-4440.md: '@object-ui/core': minor, '@object-ui/plugin-list': minor. Never major, per the version-alignment rule.

The measurement, run as part of reverse verification: unlike #4439 — where the emitted .d.ts was byte-identical and minor rested on runtime behaviour alone — here the published type itself moves:

-export declare function rowHeightToDensityMode(rowHeight: unknown): DensityMode;
+export declare function rowHeightToDensityMode(rowHeight: unknown): DensityMode | undefined;

The function is re-exported by packages/core/src/index.ts:72, so it is published surface, and a host assigning the result straight into a DensityMode must now say what an off-spec row height means to it. plugin-list is graded alongside it because its rendered output changes for the same input class (§1). patch would only have been defensible if nothing observable changed; two things do.

10. Surface discipline and out-of-scope findings

Touched: packages/core/src/utils/normalize-list-view.ts + its test, packages/plugin-list/src/ListView.tsx + one new test, one new test in packages/react/src/spec-bridge/__tests__/, and the changeset. Nothing in app-shell/console/ai/**, eslint-rules/**, root config, or the #4425 sweep suite. No bridge source was edited.

Filed unassigned, not fixed here:


Generated by Claude Code

…ight (#4440)

`@object-ui/core` coerced any unknown `rowHeight` to `comfortable`, while
`@object-ui/react`'s spec bridge declined to answer for the same string after
#4352 (PR #4439) — one metadata-driven system holding two answers for one
input. AGENTS.md #0.1 decides which survives: the strict one. An off-spec
`rowHeight` now yields no density, and the caller's own "nothing was said"
default applies.

`ListView`, the single in-repo caller, lands that abstain on `'compact'` — the
default it already used for an ABSENT `rowHeight`, and the one `ObjectGrid`
picks independently. It deliberately does NOT let `undefined` reach
`useDensityMode`, whose parameter default is `'comfortable'`: that compiles and
would rebuild the retired coercion one frame lower.

Also closed: the lookup guarded membership with `in`, which walks the prototype
chain, so `rowHeight: 'toString'` returned `Object.prototype.toString` — a
function — from something typed `DensityMode`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 12, 2026 8:47am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-BE4AaHEo.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
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) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 489.20KB 108.43KB
core (index.js) 2.99KB 1.14KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.42KB 41.19KB
fields (index.js) 228.99KB 56.82KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 62.01KB 17.63KB
plugin-chatbot (index.js) 181.17KB 43.03KB
plugin-dashboard (index.js) 120.75KB 31.38KB
plugin-designer (index.js) 211.16KB 42.76KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 187.99KB 49.92KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.20KB 26.79KB
plugin-map (index.js) 18.05KB 5.80KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.99KB 10.74KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
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) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 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-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
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 (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
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

Copy link
Copy Markdown
Collaborator Author

ACCEPT — PM 复核 (session session_017Qqyix2QcnpUC9XeYVDzx3), closes #4440.

Flipping ready + arming auto-merge.


Generated by Claude Code

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(core): rowHeightToDensityMode coerces an off-spec rowHeight to comfortable, the opposite of what the spec bridge now does with the same input

2 participants