Skip to content

finding(react): mapDensity still coerces a non-string rowHeight to a density, where the core twin abstains #4459

Description

@yinlianghui

Found while implementing #4442 (PR #4457, the hasOwnProperty guard on mapDensity). Not fixed there: #4442's ruling scoped the work to the prototype-chain index and the prototype-member spellings, and this is a different defect class — coercion, not an unguarded prototype read. Same relationship #4442 itself had to #4440.

What

After #4442 lands, packages/react/src/spec-bridge/bridges/list-view.ts reads:

function mapDensity(rowHeight?: RowHeight) {
  if (!rowHeight) return undefined;
  if (!Object.prototype.hasOwnProperty.call(ROW_HEIGHT_TO_DENSITY, rowHeight)) return undefined;
  return ROW_HEIGHT_TO_DENSITY[rowHeight];
}

The first guard rejects only falsy values. Any truthy non-string survives it, and both hasOwnProperty.call and the index then coerce the key with String(...) — so a value that is not a string at all can still select a density.

The core twin does not do this. packages/core/src/utils/normalize-list-view.ts:83 opens with a type guard, not a truthiness guard:

export function rowHeightToDensityMode(rowHeight: unknown): DensityMode | undefined {
  if (typeof rowHeight !== 'string') return undefined;
  ...

So the two surfaces disagree again, on a different input family:

rowHeight core bridge
['compact'] undefined 'compact'
new String('compact') undefined 'compact'
{ toString: () => 'compact' } undefined 'compact'

Measured through SpecBridge.transformListView (whose parameter is any — the untyped boundary a host's stored JSON actually crosses) while implementing #4442, and reproduced standalone against both function bodies.

Note the direction: this is the opposite of #4442. There the bridge leaked a function where core abstained; here the bridge invents a legitimate-looking density where core abstains. The second is quieter — nothing downstream can tell the value was fabricated from an array.

Why it matters beyond the two functions

packages/react/src/spec-bridge/__tests__/RowHeightDensityAgreement.test.ts is titled "core and the spec bridge give one answer", and after #4442 its off-spec lists are all strings. The invariant the file claims is therefore broader than the invariant it pins, and this is the gap. A JSON-authored view can hold an array or an object at rowHeight just as easily as a bad string.

Impact

No user hits this today, on the same evidence as #4442: the three-repo sweep in PR #4439, re-run at newer HEADs for #4440, found no off-spec rowHeight authored anywhere at all — let alone a non-string one. Filed as a defect rather than an observation because it is a live disagreement between two published surfaces that a dedicated pin file exists to prevent, but grading it is triage's call, not the filer's.

The fix, when someone takes it

Mirror core's opening guard, one line:

if (typeof rowHeight !== 'string') return undefined;

replacing the if (!rowHeight) truthiness check ('' is falsy and not an own property, so the empty-string case is unaffected either way). Worth extending the agreement pin's invariant loop to a non-string family (['compact'], new String('compact'), an object with a toString) in the same PR, since that is the assertion this currently breaks.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions