feat(android): render intrinsic sheets and box semantics - #39
Merged
Conversation
Four findings from an adversarial pass, one of them a hard crash. All verified
on a physical Moto G power (2021), including a negative control.
1. CRASH — an intrinsic sheet containing a scrollable child killed the app.
The content-detent path wrapped the sheet body in verticalScroll
unconditionally. Compose's checkScrollableContainerConstraints THROWS when
a scrollable is measured with an infinite max height, so a `scroll` or
`lazy_list` inside `detents: [:content]` — the most natural use of an
intrinsic sheet — died at first measure with
FATAL EXCEPTION: java.lang.IllegalStateException: Vertically scrollable
component was measured with an infinity maximum height constraints
iOS survives the equivalent nesting because SwiftUI tolerates it; Compose
does not. The sheet now only adds its own scroll when the content has none
(isScrollableNode walks the subtree); the height cap applies either way and
the child keeps owning its scrolling.
Proven, not assumed: with the guard disabled the app dies with that
exception; with it, the same screen reports 40 rows and stays alive.
2. A disabled box still dispatched taps unless it also carried
accessibility_role: :button. The non-button `when` arm had no `enabled`
check while the semantics block below emitted disabled() — TalkBack
announced it as disabled and it kept firing. iOS disables every box
regardless of role, so this was also a platform split.
3. A box declared `accessibility_role: :button` whose tap is handled by an
ancestor was announced as "…, button, disabled". Compose publishes
disabled() semantics whenever clickable's `enabled` is false, so encoding
"no handler" as enabled = false mislabelled a live control. The branch now
requires a handler and falls through to semantics-only otherwise.
4. The height cap sat outside the node's own padding, so a padded sheet
overshot max_height by the padding amount and disagreed with iOS, which
caps the already-padded body. Modifier order corrected.
Also: merge descendants on label OR button role, matching the iOS fix — role
without merging leaves children as separate nodes inside a "button".
Device results (Moto G power): short content hugs (3 rows), max_height 320 caps
and scrolls internally (40 rows), scrollable child renders and survives (40
rows), app alive across all cases.
340 tests, format, credo --strict, ktlint generate-then-lint, and a real Gradle
build all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GenericJam
added a commit
that referenced
this pull request
Aug 28, 2026
…crash fix Pairs with mob 0.7.32, published first. Ships #39: the generated Android renderer parses typed content detents, hugs short content, caps against max_height and the parent constraint, scrolls overflow internally, and honours composite Box accessibility (label as contentDescription, explicit button role preserved without a tap handle, disabled semantics, no dispatch when disabled, passive labelled boxes stay non-buttons). Plus four fixes from an adversarial review, one of them a hard crash: - A content Sheet containing a scroll/lazy_list child KILLED THE APP. The content-detent path wrapped the body in verticalScroll unconditionally, and Compose throws when a scrollable is measured with infinite max height. Proven on a Moto G with a negative control: guard disabled, the app dies with IllegalStateException; guard enabled, the same screen renders 40 rows and survives. - A disabled Box dispatched taps unless it also had a button role, while reporting disabled() semantics — announced disabled, still firing, and a split from iOS which disables every Box regardless of role. - A Box declared a button whose tap is handled by an ancestor was announced as "button, disabled", because Compose publishes disabled semantics whenever clickable's enabled is false. - The height cap sat outside the node's padding, so a padded sheet overshot max_height; iOS caps the already-padded body. Generated dependency floor raised to {:mob, "~> 0.7.32"}. The comment now records both reasons a floor exists here — mob_send_dismiss since 0.7.31, and 0.7.32's validation/encoding of detents and accessibility props, without which the Kotlin silently renders a default sheet and drops the semantics rather than erroring. Preflight: format, credo --strict, compile --warnings-as-errors, 340 tests, git diff --check, ktlint generate-then-lint, and a real Gradle build + device deploy all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ownership
This is the generated Android half of the coordinated Mob renderer change. The downstream UI library remains responsible for Select/BottomSheet composition and visual styling; the generated host owns Material Sheet mechanics and generic Box semantics.
The matching runtime/iOS implementation is GenericJam/mob#94.
Verification
mix format --check-formattedmix credo --strictmix compile --warnings-as-errorsMOB_DIRandMOB_DEV_DIR(339 total behavior checks green)mix test --only lint— generated Kotlin lint passedgit diff --check origin/master..HEADThis PR intentionally does not bump the archive version or generated Mob dependency floor. Release bookkeeping follows after the matching Mob release is approved.