devShakib

CanvasKit Layout Traps: The Unbounded Constraint Bug That Only Blanks Release Builds

A roulette board rendered blank in production while flutter analyze stayed green. The CanvasKit unbounded constraint trap, why release hides it, and how to catch it.

I shipped eight card and casino games to my portfolio in a single commit — solitaire, roulette, video poker, slots, baccarat, keno, war, higher-lower. All client-side Flutter web, all free, all deployed to Firebase Hosting in one push. flutter analyze was clean. I read the diff twice. The build succeeded. I deployed.

Then I opened /games/roulette on the live site and got a page with a header, a subtitle, a bankroll readout, a spin button — and a completely blank rectangle where the betting board should have been. No red error screen. No console exception. No 404. Just an empty region the size of the thing that was supposed to be there, on a page where everything else rendered perfectly.

The cause was one enum value: CrossAxisAlignment.stretch on a Row that, four widgets up the tree, was sitting inside a scroll view. In debug that combination throws a loud, well-written framework error. In release the assertion that produces that error doesn't exist, so nothing throws at all — the framework computes with infinity and paints nothing. A layout contract violation is not a type error, and no amount of static analysis is going to find it for you. This post is that bug in full, the family of unbounded-constraint traps it belongs to, why debug builds give you a false sense of safety, and the verification discipline I now refuse to skip.

Eight games shipped, one board rendered nothing

The symptom is worth describing precisely, because it's what makes this class of bug so slow to diagnose.

The route loaded. The page scaffold — nav, page header, back link, related-games strip — was all there and correct. Analytics fired the pageview. The bankroll, the chip selector and the spin control rendered. Only the number grid, the largest single widget on the page, drew nothing at all. The space it occupied wasn't even collapsed to zero; it was just empty.

The browser console was clean. Not "clean apart from a warning" — genuinely empty. Chrome DevTools' Elements panel showed what it always shows for a CanvasKit app: <flt-glass-pane> and a <canvas>. There is no DOM for a Flutter widget under CanvasKit, so there was nothing to inspect, no computed style to check, no element with an unexpected height: 0. And a release build has no VM service attached, so the Flutter DevTools widget inspector can't connect either.

That is the actual debugging position you're in: a screenshot and an empty console. Everything you'd normally reach for — the widget inspector, the layout explorer, the render tree dump, the error message itself — is a debug-mode facility, and the bug only manifests where none of them exist.

What actually threw

The offending widget builds the roulette number grid: a zero cell plus a 12×3 grid of pockets, horizontally scrollable so it fits on a phone.

Widget _numberGrid() {  return SingleChildScrollView(    scrollDirection: Axis.horizontal,    child: Row(      crossAxisAlignment: CrossAxisAlignment.stretch, // ← the bug      children: [ /* zero cell, then the 12x3 grid */ ],    ),  );}

Read it in isolation and it looks fine. It reviews fine. It is, in isolation, legal — that exact widget is perfectly valid inside a SizedBox(height: 140). The violation only exists because of where it ended up.

Where the unbounded axis comes from

Here's the part that most explanations get subtly wrong. A horizontal SingleChildScrollView does not create an unbounded height. It creates an unbounded width — that's the scroll axis — and it forwards the parent's height constraint straight through to its child. Internally the viewport computes its inner constraints as constraints.heightConstraints(): width becomes 0..infinity, height is passed along untouched.

So whether the child gets a bounded height depends entirely on what's above the scroll view. And in my app, what's above it is this:

By the time the Row is laid out it has BoxConstraints(0.0 <= w <= Infinity, 0.0 <= h <= Infinity). The trap is the combination — a page-level vertical scroll view plus a horizontal one nested inside it — not either widget on its own. That's exactly why it doesn't reproduce in a minimal repro and why it only bit one of eight new screens.

What stretch does with an infinite cross axis

CrossAxisAlignment.stretch means "make every child fill the cross axis." RenderFlex implements that by laying children out with a tight constraint on the cross axis, taken from its own incoming maximum. For a Row, that's:

BoxConstraints.tightFor(height: constraints.maxHeight)

When constraints.maxHeight is double.infinity, that becomes tightFor(height: Infinity) — every child is ordered to be exactly infinitely tall, and the flex's own height resolves to infinity too.

In a debug build, RenderBox checks its constraints and throws:

BoxConstraints forces an infinite height.The offending constraints were:  BoxConstraints(0.0<=w<=Infinity, h=Infinity)

You get that in the console, and ErrorWidget paints the yellow-and-black striped error box in place of the subtree with the message on it. It's one of the better errors in the framework: it tells you the constraint, the render object and roughly where to look.

In a release build, none of that happens — and it's worth being precise about why, because the common phrasing is misleading. Release doesn't suppress the error box. Release strips the assert that produces the error, so there is no error. The layout completes "successfully" with a height of infinity. Everything downstream — the paint bounds, the layer transform, the clip rect handed to Skia — is computed from a non-finite number. CanvasKit gets geometry it can't rasterise and draws nothing, and because the failure is arithmetic rather than exceptional, nothing anywhere reports a problem.

The related trap sits one layer over: for exceptions that do survive into release, like one thrown in a build() method, ErrorWidget.builder's default in release renders a plain grey box with no text. So the two release behaviours are "silent wrong geometry" and "silent grey rectangle". Neither of them tells your user, or you, anything.

The fix, and the comment I left behind

The repair was one line, because the cells already carried explicit sizes — stretch was never doing any work in the first place, it was cargo-culted in from another layout where the parent was bounded:

child: Row(  crossAxisAlignment: CrossAxisAlignment.start, // was: .stretch  children: [    _numberCell(0, height: cell * 3 + gap * 2, width: cell),    // ...  ],),

I also left a comment above it, which I don't normally do for a one-word change:

// NB: a horizontal scroll view passes the parent's height constraint through,// and this page lives inside a vertical scroll view — so the Row must not use// CrossAxisAlignment.stretch (that throws on an unbounded cross axis and// silently blanks the whole board in release).

The comment is there because the code looks wrong now. start on a row of fixed-height cells reads like a redundant argument, and the next person to touch this file — including me, in six months — has every reason to "tidy it up" back to stretch. When the correct value looks arbitrary, the constraint that makes it correct belongs in a comment, because it isn't visible anywhere in the file.

Why debug gives you a false sense of safety

Here's the uncomfortable part of my own post-mortem: debug would have caught this instantly. Anyone who opened /games/roulette in a debug run would have seen a striped error box with the exact constraint printed on it. The bug shipped because in a batch of eight new screens, that page was never opened. It was written, analyzed, reviewed and built — and the one step nobody performed was looking at it.

That's the honest failure, and it generalises into a rule I now take seriously: flutter analyze passing, the build succeeding and a human reading the diff are three checks that all look like verification and none of which execute a single line of layout code.

The second-order problem is that debug and release don't merely differ in how loudly they complain — they differ in behaviour. Assertions run in one and not the other. Icon tree-shaking runs in release and not debug. Const canonicalisation, timing, frame budget and error-widget rendering all diverge. So "it worked in debug" is a weaker statement than it feels like, and "it was never run in debug either" is the worst position of all. The practical consequence is that both passes are load-bearing: debug tells you what is wrong with a readable message, and release tells you whether anything silently vanished.

The rest of the unbounded-constraint family

stretch inside a scroll view is one member of a family, and they all share a shape: a widget that needs a bounded axis, placed under a parent that supplies an unbounded one. These are the ones I've hit in production.

Expanded and Flexible inside a scroll view

Expanded divides the remaining space on the main axis. Inside a vertical SingleChildScrollView, a Column has infinite main-axis space, so there is no "remaining" to divide. Debug says so clearly:

RenderFlex children have non-zero flex but incoming height constraints are unbounded.

Release strips the assert and you're back to infinite geometry. The fixes are to give the axis a real bound — a SizedBox/ConstrainedBox, a LayoutBuilder handing down constraints.maxHeight, or CustomScrollView with SliverFillRemaining when you genuinely want "fill the viewport, but scroll if it overflows."

ListView inside a Column

The same shape wearing different clothes. A ListView wants an unbounded main axis for lazy building, but placed in a Column that's already inside a scroll view it gets one and throws Vertical viewport was given unbounded height.

shrinkWrap: true makes it compile and is the wrong reflex most of the time: shrink-wrapping lays out every child immediately to measure the total, which is precisely the laziness you chose ListView for. On a list of 30 that's free; on a list of 3,000 it's a frozen frame. Prefer slivers in one CustomScrollView, or bound the height, and reach for shrinkWrap only when the list is genuinely short and finite.

IntrinsicHeight, and what it actually costs

IntrinsicHeight is the standard answer to "make these cards match heights", and it does work. Two things to know before you reach for it. It runs an extra speculative layout pass over its subtree to compute the intrinsic dimension, so the subtree is measured twice; the framework's own documentation flags it as relatively expensive and warns it can degrade badly when nested. And it throws when a descendant can't answer an intrinsic query — several widgets, scroll views among them, don't support intrinsics and assert when asked.

For the roulette grid, explicit width/height on the cells was both cheaper and impossible to get wrong. I now treat intrinsics as a last resort rather than a default reach.

Nested scrollables on the same axis

Two scrollables on the same axis is a double failure mode: the inner one may be handed an unbounded main axis, and the gesture arena has to pick which one receives the drag. The typical symptoms are an unbounded-viewport assert in debug, or — more confusingly — a widget that renders perfectly but simply refuses to scroll. NestedScrollView, or collapsing both into a single sliver-based CustomScrollView, is the real fix. NeverScrollableScrollPhysics plus shrinkWrap is the pragmatic patch when the inner list is short.

Why static analysis structurally cannot catch this

It's tempting to file this under "we need a better lint." It's worth understanding why that only half works.

flutter analyze reasons about types, nullability, unused code and a rule set applied to the syntax tree. CrossAxisAlignment.stretch is a valid value of a valid enum passed to a valid named parameter of a valid constructor. Every type checks. There is nothing ill-formed about the code.

The rule that got broken lives somewhere the analyzer cannot see: "a Row with stretch requires a bounded height" is a runtime precondition of the layout protocol, encoded as an assert inside RenderFlex, not as anything in the type system. And the precondition isn't a property of the widget at all — it's a property of the widget plus its ancestor chain. The exact same _numberGrid() is correct under a SizedBox and broken under a scroll view. To flag it statically, the analyzer would have to know every ancestor chain the widget can appear in, and mine was assembled at runtime out of a Map<String, Widget Function(BuildContext)> in a deferred registry, across four files. That's not a lint; that's whole-program abstract interpretation of a tree that doesn't exist until the app runs.

Custom lints are still worth having for the syntactic cases — a custom_lint rule that flags CrossAxisAlignment.stretch literally nested inside a horizontal SingleChildScrollView in one expression would catch the textbook version. It just wouldn't have caught mine, because the two halves were nowhere near each other.

Static analysis validates the code you wrote. Layout is a conversation between a widget and a parent it has never met.

The discipline that actually catches it

Three habits, in increasing order of leverage.

Screenshot every new screen, in a real browser

Not flutter run -d chrome alone — that's the debug compiler and a different renderer path from what you deploy. Build release, serve build/web, and open every route you added:

flutter build web --releasecd build/web && python3 -m http.server 8080

Then walk the list. Every new route, at a narrow width and a wide one. It takes under a minute per screen and it is the only check that observes what a user will observe. My rule now is blunt: a screen I haven't seen rendered by a release build isn't shipped, it's uploaded.

Do the debug pass too. Debug gives you the readable error, release gives you the silent one — you want both signals, in that order.

Grep for the dangerous pairings

Blunt and effective. In my repo CrossAxisAlignment.stretch appears at 88 call sites across 60 files, and scrollDirection: Axis.horizontal appears 28 times. Reviewing 88 sites is a chore. But the intersection is what matters:

comm -12 \  <(grep -rl "CrossAxisAlignment.stretch" --include="*.dart" lib/ | sort) \  <(grep -rl "scrollDirection: Axis.horizontal" --include="*.dart" lib/ | sort)

Nine files. That's a five-minute review, not a five-hour one. The same trick works for Expanded co-located with SingleChildScrollView, and for ListView inside a file that also contains Column(. It's a heuristic with false positives, and it's still the cheapest sweep available.

Pump every route in a widget test

This is the one that actually closes the hole, and it's about twenty lines. Widget tests run in debug mode, which means the assertions are live — the same assert that would have printed a striped error box becomes a red test in CI.

void main() {  for (final entry in kGameBuilders.entries) {    testWidgets('${entry.key} lays out at 3 widths', (tester) async {      for (final width in [360.0, 768.0, 1440.0]) {        tester.view.physicalSize = Size(width, 900);        tester.view.devicePixelRatio = 1.0;        addTearDown(tester.view.reset);        await tester.pumpWidget(          MaterialApp(home: Scaffold(body: Builder(builder: entry.value))),        );        await tester.pump();        expect(tester.takeException(), isNull);      }    });  }}

Because the games live in a registry keyed by slug, the loop is free — every game I add from now on is covered the moment it's registered, with no per-game test to remember. Wrap the widget in whatever your real ancestor chain provides, including the scroll view, or you'll reproduce the bounded case and miss the bug entirely. That detail is the whole test: it has to reproduce the constraints your app actually delivers, not a convenient bounded box.

Finally, wire up release error reporting — FlutterError.onError forwarding to your analytics, and a custom ErrorWidget.builder that shows something honest instead of a grey rectangle. It won't catch this particular bug, because the assert was compiled away and nothing was ever thrown. It will catch every build-phase exception that would otherwise ship as a silent grey box.

Key takeaways

The thing I keep coming back to is that every automated check in my pipeline was green while a user was staring at an empty rectangle. That isn't a gap in the tooling so much as a category error on my part: the analyzer's job is the code, and this bug wasn't in the code — it was in the relationship between two widgets that never appear in the same file. Layout correctness is observed, not proven. Open the page in a release build, or write the test that opens it for you, and stop calling "the build succeeded" verification.