Skip to content

MOB-128: Android :scroll composes only what is on screen - #46

Merged
GenericJam merged 2 commits into
masterfrom
perf/mob-128-lazy-scroll-v2
Sep 2, 2026
Merged

MOB-128: Android :scroll composes only what is on screen#46
GenericJam merged 2 commits into
masterfrom
perf/mob-128-lazy-scroll-v2

Conversation

@GenericJam

@GenericJam GenericJam commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Android half of MOB-128, from the MOB-124 rendering-performance epic. No version bump.

Companion PR in mob: GenericJam/mob#118 (iOS half plus MOB-125/133/135).

What ships

A vertical :scroll renders through MobLazyList (LazyColumn) when the author opts in with lazy: true and its content can be lazified. Otherwise it keeps the eager Column + verticalScroll.

Opt-in on purpose. Laziness is not free of observable consequences: rows below the fold are never composed, so they never register a frame — Mob.Test.element_frames and tap_id cannot address them — and scroll position becomes index-based rather than pixel-based. lazy_list already makes exactly that trade explicitly; making it the silent default for every :scroll would change harness behaviour under apps that never asked.

Flattening, narrowly guarded. Mob screens are written scroll > column > rows, so a scroll usually has one child and lazifying its direct children buys nothing. When that column's own props are all in {fill_width, fill_height} — genuinely droppable, since a LazyColumn already spans its width and fill_height is a no-op under an unbounded main axis — its children become the items. Anything else (padding, background, align, an id, a tap handler) keeps the eager path. A child carrying weight also forces the eager path: weight comes from ColumnScope and LazyColumn items have none, so it would vanish without a trace.

Result

Single-variable A/B — same build, same fixture, toggling only lazy: true. 500 rows, Moto G Power, main-thread frame cost:

p50 max recompose max
eager 498.9 ms 1385.9 ms 1197.7 ms
lazy: true 115.8 ms 164.8 ms 69.7 ms

4.3x on p50, 8.4x on the worst frame. Lazy cost is flat in list length where eager grows; short lists are marginally slower, which is another reason it is opt-in.

Changed by adversarial review

This PR was reworked after review. Three things it caught:

A critical harness bug. scrollInfo checks scrollState before lazyState, and the rememberScrollState() above the branch is never attached to a verticalScroll on the lazy path — so its maxValue keeps its Int.MAX_VALUE default. A lazified :scroll with an id reported kind: "pixel" with max_y ≈ 2.1e9, which made scroll_to(:bottom) return :ok without moving the list and screenshot_tour compute roughly a million pages. Fixed: the lazy path clears it.

A silent API loss. Flattened children lost weight. Now guarded.

A fix built on a wrong diagnosis, removed. An earlier revision gave a fill_height scroll child Modifier.weight(1f), justified as "Column only measures a child against the remaining space when that child is weighted". The review disproved that from the shipped foundation-layout bytecode — a non-weighted child of a bounded Column is measured with mainAxisMax = remaining. The weight was also harmful: inside an unbounded Column the weighted branch computes targetSpace = mainAxisMin = 0, so the child measured at zero height, and it changed weight distribution for apps that never touched the lazy path. Removed entirely, and verified on device that removing it does not reintroduce the blank list that motivated it — that was MOB-136 (a text_field under an unbounded-height container blows the row height to ~500 px, which broke the eager path identically).

Tests

Five template-assertion tests pin the opt-in check, the neutral-prop set, the weight guard, the ScrollState clearing, and the absence of the removed weight hunk.

mix test → 351/353. The two failures (--python project compiles cleanly, liveview_generate/3 ... --local) fail identically on master — confirmed by the reviewer in a clean worktree at 34831c4. CI is green.

Verification

Generated an app from the patched template and built + deployed it to an emulator; screenshotted every measurement to confirm rows actually render. That is not ceremony — an earlier revision reported 1617 → 24 composables and 312 → 78 ms while rendering a blank list. Only pixels caught it.

GenericJam and others added 2 commits September 2, 2026 09:42
Measured on a Moto G Power, one render at a time so no frame queues behind
another: a 200-row screen cost 134 ms of main-thread work per update, 107 ms of
it Compose recomposition, against 45 ms for the entire BEAM-plus-NIF pipeline.
The native rebuild is the dominant cost, and :scroll composed every child
regardless of the viewport — 200 rows composed to show about ten.

Vertical :scroll now renders through MobLazyList when its content can be
lazified. Two things had to be solved and they are the substance of this change.

Mob screens are written scroll > column > rows, so a scroll node usually has
exactly ONE child; lazifying its direct children buys nothing because the column
underneath still composes every row. When the sole child is a column whose own
props are layout-neutral, its children become the items. Only fill_width and
fill_height count as neutral — a LazyColumn already spans its container's width
and takes its height from the scroll node. Padding, background, align, or an id
the harness addresses would be silently dropped by flattening, so those keep the
eager path.

A lazy container must be measured with a bounded main axis. Column only measures
a child against the remaining space when that child is weighted, so fill_height
alone left it unbounded. verticalScroll tolerates that; LazyColumn does not — it
composes zero items and renders an empty screen. A fill_height scroll child now
gets Modifier.weight(1f) from its parent column.

MobLazyList is reused rather than a fresh LazyColumn so :scroll inherits the
list-state hoisting already there: rememberLazyListState resets to 0 on every
BEAM re-render.

Main-thread frame cost, p50 / max:

  50 rows   eager  77.8 / 106.7 ms    lazy  83.2 / 120.4 ms
  200 rows  eager 140.9 / 162.1 ms    lazy  81.5 / 107.5 ms
  500 rows  eager 104.0 / 307.9 ms    lazy  91.6 / 110.9 ms

The shape matters more than the percentage: lazy cost is flat in list length
while eager grows. At 50 rows lazy is slightly worse, which is expected — the
list nearly fits on screen so there is little to skip and LazyColumn has setup
cost. At 500 rows the eager p50 is unreliable (under backpressure the app draws
less often, so fewer heavy frames are sampled); its max of 308 ms against 111 ms
is the honest comparison.

This does not make Compose skip anything — skipping is separately broken, and a
composable with a literally constant argument still recomposes every frame here.
Laziness works by composing fewer nodes, not by reusing unchanged ones.

Verified by generating an app from the patched template and building it, and by
screenshotting every measurement. That last part is not ceremony: an earlier
version of this change reported 1617 -> 24 composables and 312 -> 78 ms while
rendering a blank list. Both numbers looked like a triumph. Only pixels caught it.

No version bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gnosis

An adversarial review found one critical bug, one silent API loss, and — most
usefully — disproved the justification for half this change from the Compose
bytecode. Reworked accordingly.

## Laziness is now opt-in via `lazy: true`

The review established that making it the default silently degrades the test
harness for apps that never asked: rows below the fold are never composed, so
they never register a frame and Mob.Test.element_frames / tap_id cannot address
them, and scroll position becomes index-based rather than pixel-based.
`lazy_list` already exists and makes exactly that trade explicitly. So the author
asks for it.

This also answers the review's observation that the guard is narrow enough that
the optimisation would rarely fire on real screens — the generator's own starter
templates use `scroll > column padding: background:`, which the guard correctly
refuses. Opt-in makes that a documented instruction (move those props onto the
scroll) rather than silent non-application.

## The critical bug: a detached ScrollState left registered

`scrollInfo` checks `scrollState` before `lazyState`, and the `rememberScrollState()`
above the branch is never attached to a `verticalScroll` on the lazy path — so its
maxValue keeps its Int.MAX_VALUE default. A lazified `:scroll` with an id therefore
reported kind "pixel" with max_y ~2.1e9, which made `scroll_to(:bottom)` return :ok
without moving the list, and `screenshot_tour` compute roughly a million pages.
That is a silent false pass and an unbounded hang, on the exact node shape this
change targets. The lazy path now clears it.

## The silent API loss: weight

A flattened column's children lose `weight` — it comes from ColumnScope and
LazyColumn items have none. `weight` is documented public API. Any child carrying
it now forces the eager path.

## The wrong diagnosis, removed

The first version gave a `fill_height` scroll child `Modifier.weight(1f)`,
justified as "Column only measures a child against the remaining space when that
child is weighted". The review disproved this from the shipped foundation-layout
bytecode: a non-weighted child of a bounded Column is measured with
mainAxisMax = remaining. Bounded. weight only raises min to equal max.

The weight was also actively harmful — inside an unbounded Column (a nested
scroll) the weighted branch computes targetSpace = mainAxisMin = 0, so the child
measured at zero height and vanished; and it changed weight distribution for
apps that never touched the lazy path. Removed entirely.

The blank list that motivated it is explained by MOB-136: on Android a text_field
in a row under an unbounded-height container blows the row height to ~500 px so
its siblings are centred off screen. The eager path rendered nothing for the same
reason, which only a screenshot of the eager baseline established. Verified on
device that removing the weight does not reintroduce the blank list.

## Evidence

Single-variable A/B, same build and fixture, toggling only `lazy: true`, 500
rows on a Moto G Power: main-thread frame cost p50 498.9 ms -> 115.8 ms, worst
frame 1385.9 ms -> 164.8 ms. That also proves the flag switches paths.

Five template-assertion tests pin the opt-in check, the neutral-prop set, the
weight guard, the ScrollState clearing, and the absence of the removed weight
hunk.

No version bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam
GenericJam merged commit 107f0b6 into master Sep 2, 2026
3 checks passed
@GenericJam
GenericJam deleted the perf/mob-128-lazy-scroll-v2 branch September 2, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant