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 → commitThat 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.