The 57pt layout jump that moved a button mid-tap

The 57pt layout jump that moved a button mid-tap
On a physical Android device, we watched the most-pressed button on Cubist's home screen slide 57pt up the screen — not on a transition, not on a scroll, but at the arbitrary moment a background query happened to finish.
The problem
The home screen leads with a personal-best hero: an overline reading PERSONAL BEST, then the time. Directly under it sits START TIMER. That button is the reason the screen exists — it is the one control a cuber taps every session, often several times a minute.
A few days earlier we had made a decision about it: the primary slot is state-invariant. Whatever your entitlement, whatever you have saved, START TIMER keeps the same position and the same label. Saved solves moved into a resume card below it. We wrote the decision down and locked it with tests.
Then an end-to-end run on an emulator caught this:
loading START TIMER top = 393.8
resolved START TIMER top = 336.8
57pt, three runs, identical coordinates. We had pinned the saved-solve axis and left the loading axis wide open.
Two details made it worse than a cosmetic wobble. First, the provider backing the hero is autoDispose — it tears down when you leave home, so the loading state replays every time you come back, which for a cubing app is after every single solve. Second, that loading window measured over 900ms on device. That is comfortably long enough for someone to see the screen, decide, and start moving a thumb.
Why it happened
The hero has three states, and they are not the same height:
- Loading — a placeholder rendered in the mono time face, 78pt type, one line.
- Resolved with a personal best — the same face, same size, one line.
- Resolved with no personal best yet — a headline plus two lines of prose.
The two mono states are the tall ones. The empty state is the short one. Nothing reserved space, so the column simply took whatever its child asked for, and everything underneath followed.
Which means the jump belonged to exactly one population: people who don't have a personal best yet. A brand-new user opens the app, sees a placeholder where their time will go, and 900ms later the whole screen settles upward — including the button they are reaching for. The one cohort with no muscle memory for the layout got the only version of the layout that moves.
What we tried first
Nothing clever, and that is the uncomfortable part. In the same batch of work we had already fixed this exact class of bug one line further down: the remaining-solves line was jumping 28pt for the same reason, and we reserved height for it. Same diagnosis, same prescription, done.
Then we stopped measuring. The bigger instance was sitting directly above the one we fixed, in the same widget tree, and we did not look for it. It took a device to find what a second read of the file would have.
The fix
Reserve the height, sized to the tallest state:
/// Minimum height the PB hero occupies in **every** state —
/// loading, empty, resolved.
///
/// The tallest state is the value (and the placeholder, which uses
/// the same face). If the reservation doesn't cover it, everything
/// below jumps the moment the query resolves.
@visibleForTesting
const double kPbHeroMinHeight = 132;
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: kPbHeroMinHeight),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('PERSONAL BEST', style: CubistText.monoOverline()),
const SizedBox(height: CubistSpace.s2),
child,
],
),
);
Three lines of behaviour change. The interesting work was everywhere else.
Before and after
| Before | After | |
|---|---|---|
START TIMER offset, loading → resolved |
393.8 → 336.8 | unchanged |
| Vertical shift on resolve | 57pt | 0pt |
| Occurrences per session | every return to home | none |
The test could not see the bug
Here is the part that cost real time. We wrote a widget test to reproduce the 57pt gap, and it came back green — with the fix reverted.
The runner's fallback font is not the font that ships. It inflates our mono face badly enough that the height difference the device measured simply does not exist in the test environment: 170.5 under the fallback against 122 measured on hardware. A widget test asking "does the block change height between states?" answers "no", confidently, for a reason that has nothing to do with the product.
So we stopped asking the test to see the symptom. It now asserts a different thing:
const measuredOnDevice = 122.0;
expect(kPbHeroMinHeight, greaterThanOrEqualTo(measuredOnDevice));
Does the reservation cover what we actually measured on hardware? Shrink the constant and it goes red — we checked by mutation, which is the only way to know a guard is load-bearing. The symptom itself stays locked by an end-to-end case that runs on a real device, where the fonts are real.
There was a second finding hiding underneath. Our end-to-end suite had a test whose name contained a /. The Android test orchestrator builds a file from the test name, and a path separator in that name throws — killing the run partway through. The suite still reported green, because the cases that had already passed were counted and the crash was not. Fixing the names took the executed count from 2 to 11, and three of the newly-visible cases were failing. We now have a repository guard that rejects / in a test name, and it caught three more the day it landed.
What we learned
- Reserve height for anything asynchronous, not just for the case you noticed. A skeleton that is a different size from its resolved state is a layout shift with a delay fuse. If you fix one, audit the siblings in the same tree the same afternoon.
- A test environment that substitutes fonts cannot measure layout. When the environment can't reproduce the symptom, assert the invariant instead — and prove the assertion is load-bearing by breaking it on purpose.
- A green test run is a claim about how many tests ran, not just how many passed. Ours was reporting a pass rate over a suite that had died two cases in. Assert the count.
What's next
Two things are still open. The end-to-end case that locks the symptom needs a re-run against the fixed build before we call it verified — it was written red. And the underlying provider is still autoDispose, so we are re-querying the personal best on every return to home; the reservation makes that invisible, but it doesn't make it free. Caching that value is a separate change, with its own staleness question to answer.
Try Cubist
Cubist teaches the solve, times it to WCA standards, and puts you on a global leaderboard — in one app.
Frequently Asked Questions
Why did the START TIMER button move 57pt mid-tap on the Cubist home screen?
The button shifted because the personal-best hero component changed height between its loading and resolved states, causing the layout below it to jump. This happened especially for new users without a personal best, as the placeholder and empty states had different heights, leading to the 57pt vertical shift.
How was the layout jump issue with the START TIMER button fixed?
The fix involved reserving a minimum height for the personal-best hero component equal to its tallest state. By using a ConstrainedBox with a fixed minimum height, the layout no longer shifted when the asynchronous data loaded, preventing the button from moving mid-tap.
Why did the widget test fail to detect the layout jump bug?
The test environment used a fallback font that inflated the component's height, masking the height difference seen on real devices. As a result, the test incorrectly passed even when the bug was present, leading the team to assert height invariants based on real device measurements instead.
What lessons were learned about testing asynchronous UI layout changes?
Key lessons include always reserving space for asynchronous content to prevent layout shifts, recognizing that test environments may not replicate real device fonts or layouts, and ensuring test suites report accurate execution counts to avoid false positives.
What future improvements are planned after fixing the layout jump?
The team plans to verify the fix with end-to-end tests on real devices and address the inefficiency of re-querying the personal best on every home screen visit by implementing caching, which will reduce unnecessary background queries.
Continue reading

How we cut public-page transfer 57% by deleting our bundle config
A webpack splitChunks block named "bundle optimization" was disabling Next.js per-route splitting and shipping admin-only libraries to 1,078 public pages.

The 12-second gap that swallowed two subscriptions
A parent bought an annual subscription. The entitlement landed 12 seconds late, so our purchase path read it as a failure and recorded nothing.

The 3 GB memory leak that wasn't in the heap
Our hosting bill was 87% memory. The heap was flat the whole time — 98% of the growth was native memory V8 cannot see, caused by a 60-second framework default.