Skip to content

Element boundaries are lost by ==/hash/Codable, changing container row counts #2

Description

@Mx-Iris

Element boundaries are not observed by ==, hash, or Codable, so two values that compare equal and hash identically can render a different number of container rows — and a Codable round trip or an identity transform silently changes that layout.

This is pre-existing on main, not introduced by #1. It was found during that PR's sixth review round and deliberately deferred there, because fixing it requires an encoding-format decision that does not belong in a storage refactor. Filing it now so the deferral has a real tracking record rather than one sentence in a design document.

Reproduction

Verified by building the same driver against main and against #1's head — both produce identical output:

var oneElement = SemanticString()
oneElement.append(Group([SemanticString(Standard("alpha")), SemanticString(Standard("beta"))]))

var twoElements = SemanticString()
twoElements.append(Standard("alpha"))
twoElements.append(Standard("beta"))

oneElement == twoElements                      // true
oneElement.hashValue == twoElements.hashValue  // true

// ...yet they are different documents:
SemanticString(MemberList(level: 1, content: oneElement)).string   // 1 row
SemanticString(MemberList(level: 1, content: twoElements)).string  // 2 rows

A round trip through the type's own Codable conformance changes the layout while still comparing equal to its source:

let decoded = try JSONDecoder().decode(
    SemanticString.self,
    from: try JSONEncoder().encode(oneElement)
)
decoded == oneElement   // true
// but renders 2 rows, not 1

So does an identity transform:

oneElement.replacing { $0 }   // equal, renders 2 rows

replacing, filter, prefix, trimmingWhitespace and the other transformations all funnel through init(components: [AtomicComponent]), which resets boundaries to 1:1.

Why it matters

MemberList / Rows / BlockList treat one element as one row. A value that survives a serialization boundary — persisted to disk, sent over XPC, or merely passed through an identity transform — comes back rendering a different document, with no measure a caller can inspect to detect the change.

Fixing it means choosing

  1. Encode boundaries. Round trips become faithful, at the cost of a format change and a compatibility story for existing payloads.
  2. Include boundaries in == / hash. Equality then matches rendering, but two values with identical components stop comparing equal, which is its own surprise.
  3. Document the current behavior as intended and state that element granularity is not preserved across serialization — the cheapest option, and it at least stops the guarantee being implied.

Option 3 is arguably already half-true: granularity is not a property of the value alone in any case. Appending the same SemanticString through a generic parameter versus directly selects different overloads and yields different granularity (see docs/SeventhReviewFixes.md).

References

  • docs/SixthReviewFixes.md — the original deferral
  • docs/SettledFindings.md entry S6 — the standing disposition

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions