StatsHub Docs

Resizing

Writing layout that survives Stage Manager, Split View and a dragged Mac window — express constraints, never compute from a measurement.

The app runs in a window that changes size while you are looking at it: Stage Manager, Split View, and a Mac window someone drags. This is the rule for writing layout that survives that, and the reasoning behind the pieces that already do.

The rule

Express the layout as a constraint. Do not compute it from a measurement.

A constraint is resolved by Yoga on the native layout pass, in the same tick the container resizes. A computed layout is a round trip:

container resizes → onLayout → setState → React render → new pixel value → commit

That is one frame behind at best. During a continuous drag it is a re-render per frame, in every component that reads the measurement — which is what "the content lags behind the window" actually is.

This is also the answer to the thing you notice first: the platform's own bar buttons slide with the window while our content stutters after it. They are not faster. They are expressing a constraint where we were computing a number.

What that looks like

A column capped at a readable measure and centred in whatever it is given:

// Constraint — resolved natively, no JS involved after mount.
<View style={{ alignItems: "center" }}>
  <View style={{ width: "100%", maxWidth: 820 }}>{children}</View>
</View>
// Computation — one frame behind, and re-renders on every frame of a drag.
const inset = useContentInset(820);
<View style={{ paddingHorizontal: inset }}>{children}</View>

Both centre an 820pt column. Only the first resizes the way the nav bar does.

ContentColumn is the first form and is the only thing that should be narrowing content on a wide display. Reach for it rather than a padding.

What still has to be computed

Not everything can be a constraint. Two things genuinely cannot:

Discrete decisions. "Two columns or three" is a branch, and a branch has to re-render. That is fine — the answer changes at 700 and 1100 and nowhere else. What is not fine is re-rendering to ask it.

Real pixel values. A horizontal pager sizes each page in points and divides the scroll offset by that number. There are two of these in the app. Both are objects rather than runs of content, and both say so at the call site.

The three pieces

ScreenFrame (@statshub/ui-native/layout/screen-frame) is a container query. It fills the box the navigator gave the screen and reports that box on every layout pass. The window is the wrong witness — it does not change when the split view's columns do, so a collapsed sidebar, an opened inspector or a sheet at its own width would all go unnoticed. A measurement has no cases; the arithmetic it replaced could not help but be a case list.

It is mounted once, at the screenLayout boundary, so no route file has to remember to. The nearest frame wins, so a narrower region can mount its own.

ScreenMetricsContext carries the discrete flags — wide, columns, inspector — memoised on themselves. useDisplay() reads only this. It does not call useWindowDimensions, because that subscribes the calling component: while the flags rode along with the measured width, every screen that only wanted a column count re-rendered sixty times a second to answer a question whose answer had not changed.

ScreenFrame pays for the window subscription once, on everyone's behalf.

ScreenWidthContext carries the raw measured points, behind useScreenWidth(). It changes on every frame of a resize, so reading it opts your component into re-rendering with it. That is the right trade for a pager and the wrong one for anything the flags already answer.

Why inspector is the odd one out

Every other flag is measured against the screen's real box. inspector is measured against the window arithmetic, deliberately.

It is the flag a board reads to decide whether to open the trailing column — and that column takes its space from the measured box. Fed the measurement, a board with room would open the panel, watch its own column shrink past the threshold, close it, regain the room, and open it again. The guess is immune because it cannot see the panel.

The real fix is to stop predicting it at all: UISplitViewController already decides column-vs-sheet from minimumSecondaryColumnWidth, which the root layout declares. Reading that back instead of predicting it would leave one arithmetic where there are currently two that can disagree — and they have disagreed before, which is what the brief detour to 1060 in breakpoint was papering over.

Things that go wrong

Subtracting a panel from a measured width. The measurement is taken inside the split view's secondary column, so the sidebar and the inspector are already outside it. ContentColumn used to take a reserved prop for the pinned filters panel — correct while the width came off the window, and double-counting the moment it came off a measurement. A column bounded by its parent cannot overlap a sibling laid out beside it; that is what a split view is for.

Nesting a computed inset. Two padded boxes inset twice. Two maxWidth boxes do not — the inner one is simply bounded by the outer. Nesting is correct now rather than forbidden.

Reading the window when you mean the container. useWindowDimensions should be reserved for things that genuinely span the display: gradients, backdrops, the scrim under a hero. Those are deliberately not wrapped in a column — a background that stops short of the display is a stripe.

The cost that was accepted

ContentColumn used to render no view at all when the inset came out zero, so a phone paid nothing for it. It cannot know the inset is zero without measuring, so a phone now carries two flex nodes per wrapped row. That is a fixed, tiny layout cost in exchange for never rendering in order to resize, and it was taken knowingly.

Not done yet

The heroes — fixture, team, player, referee — still call useContentInset to pad their own content inside a full-bleed gradient. They are the same shape as ContentColumn was and can take the same treatment; they were left because the gradient underneath makes each one its own small surgery rather than one edit.

On this page