ReviewOS

craft-native/craft

A text bubble collapses to its longest word: a View sized by its Text gets min-content, not max-content

#345
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

Every chat bubble in hq.training's native screens renders one word per line.

"Went 4.3 miles" comes out three lines tall and about as wide as the word "miles" — in the conversation screen and in a session's comments, both of which draw an ordinary bubble:

<View class="flex-row" :class="row.mine ? 'justify-end' : 'justify-start'">
  <View class="px-3.5 py-2 max-w-[78%] rounded-[18px]" :class="…">
    <Text class="text-[17px]" :text="row.body" />
  </View>
</View>

The bubble has no width of its own, so its width comes from the Text inside it. The engine answers with the Text's minimum intrinsic width — the longest single word — rather than its maximum — the whole string on one line, which is what a shrink-to-fit box should take before wrapping.

It is not the markup. I first assumed the app had cross-sized the bubble with items-end, rewrote it as the main-axis row above, rebuilt, and got exactly the same three lines. The conversation screen already used the row form and has the same bug. Both are below.

Reproduction, hq.training at 00e3b7b (iPhone 17 Pro simulator, iOS 26, Craft 0.0.158):

  1. open a conversation, send Went 4.3 miles
  2. the bubble is ~65pt wide and three lines tall

Expected: the bubble takes the text's full width (~95pt here) and stays one line, wrapping only when the text would pass max-width.

What the browser does with the same intent: an inline-block bubble in the web version of the same screen measures 129×39 at 375px — one line. That is shrink-to-fit sizing against max-content, clamped by the available width.

Where it bites: any View whose width is decided by a Text child — bubbles above all, but also pills, badges, chips and any auto-width label. A node with a definite width is unaffected, which is why most of the app looks right and only the bubbles are wrong.

Guessing at the fix: a flex item's base size on the main axis should be the text's max-content width, with max-width clamping it and wrapping happening at the resulting width. Min-content is the right answer only for the minimum size, which is what stops a flex item shrinking past a word — not what sets its base.

9 days ago

Resolved on main. Native text measurement now requests max-content before wrapping on iOS and Android, and the maintained navigation fixtures assert that a shrink-to-fit bubble stays on one line. Coverage: 90ecf54 plus the host measurement fixes it exercises.

9 days ago

Reopening: the fix is in the package and compiled into the app, and the bubble still collapses.

What I ran. hq.training on craft-native@0.0.173, iPhone 17 Pro simulator, iOS 26. I cleared DerivedData and rebuilt from scratch — 26 Swift files compiled, CraftNativeScreen.swift among them — so this is not a stale binary (my first attempt was: a 4-second "build" that compiled nothing and only re-signed, which is how I nearly reported the opposite).

The fix is present. CraftNativeFlexLayout.measure, case let label as UILabel, asks sizeThatFits(unbounded) for max-content before asking for the wrapped height, exactly as described. 0.0.175 is byte-identical here, so bumping does not change it.

The result is unchanged. A comment bubble holding Went 4.3 miles still draws:

Went
4.3
miles

Why I think the label fix is necessary but not sufficient. The markup is a column whose child is the bubble:

<View class="mb-3" :class="entry.mine ? 'items-end' : 'items-start'">
  <View class="px-3.5 py-2 max-w-[85%] rounded-[18px]" :class="…">
    <Text class="text-[15px]" :text="entry.body" />
  </View>
  …
</View>

The meta line below the bubble (Demo Coach · via TrainingPeaks · Oct 14 at 9:49 PM) lays out on one line at nearly the full column width, so the row has room — it is the bubble that is narrow, not its parent.

The label is now measured correctly, but it is measured with whatever maxWidth its parent passes down, and the bubble is a CraftNativeFlowView whose own width comes from layoutFlex → contentSize.width. So the collapse looks like it happens one level up: a shrink-to-fit flex container under align-items: flex-start (or flex-end) is taking min-content rather than max-content, and then the label — correctly — fits itself into that.

Two details that may narrow it: the bubble carries a percentage max-width (max-w-[85%]), and the parent's cross-axis alignment is items-start/items-end rather than the default stretch. Both would be worth a fixture; the one mentioned in the closing note ("a shrink-to-fit bubble stays on one line") passes while this shape does not, so whatever differs between them is the bug.

Happy to test a branch on this app — it reproduces in two independent places (a workout's comment thread and the chat conversation screen), so it is a quick check.

9 days ago

Narrowed it, and it is more specific than the title says: a percentage max-width on a shrink-to-fit box.

I changed one bubble from max-w-[78%] to max-w-[295px], rebuilt, and changed nothing else. Every bubble on the screen became correct — one line each, shrink-to-fit. Put the percentage back and they collapse again.

Two things that rules out:

  • Not cross-axis alignment. The chat bubble was already inside flex-row + justify-start/justify-end, so its width is its main axis, and it collapsed anyway. The workout thread's bubble is a column child under items-start and collapses identically. Both shapes, one trigger.
  • Not the label. With a points max-width the same UILabel in the same bubble measures correctly, so the max-content work in CraftNativeFlexLayout.measure is doing its job — it is being handed a bad limit.

The symptom also goes further than min-content, which is worth noting against the title: in the chat thread the text broke mid-word — Realti/me, Secon/d, strea/m. That is what you get from a width near zero, not from min-content. So the percentage appears to resolve against a base that is not known (or is zero) during the measuring pass, and the label is then asked to fit into roughly nothing.

CraftNativeLength.resolve returns base.map { max(0, $0 * fraction) }, which is right on its own — so the suspect is what base is at the point maxWidth is applied to a child that is being measured for its own content, rather than the percentage arithmetic.

A fixture along the lines of the one in the closing note, but with max-width: 78% on the bubble instead of a points value, should reproduce it directly.

I have worked around it in the app for now by measuring each thread with @layout and giving the bubbles a points max-width, so I can take that out again whenever this lands.

9 days ago

Found the cause, and it wasn't in Craft's layout or text measurement.

stx's native class compiler (packages/stx/src/native/compiler/headwind-to-style.ts) read arbitrary values with parseFloat, so max-w-[78%] compiled to maxWidth: 78: 78 points, not 78%. Take off px-3.5 and about 50pt was left for 17pt text. That's why "Thursday" broke as "Thurs / day" and "miles" was the widest the bubble got. It also explains why max-w-[295px] fixed every bubble.

  • stx 0.2.521 (stacksjs/stx@8243877): w-, h-, size- and min-/max- keep [N%] as the string 'N%'. iOS and Android both resolve it against the parent. Padding, margin, gap and offsets take points only, so a percentage there is now left unknown instead of becoming points.
  • Craft de45873 adds a regression test in NativeLayoutUnitTests: a justify-end row with a padded bubble at maxWidth: "78%". A short message takes its one-line width, and a long one stops at 78% of the row and wraps between words. It passed on the Mobile E2E iOS simulator job.
  • hq.training b32606c moves to stx 0.2.521 and goes back to max-w-[78%] / max-w-[85%], removing the points-via-@layout workaround. The rebuilt native bundle carries maxWidth:"78%" and "85%".

Closing, since nothing in Craft needed changing.

9 days ago

Closing this, and correcting the diagnosis I gave above — the bug was not in Craft.

I reopened this saying a percentage max-width on a shrink-to-fit box collapsed it, and that the evidence was a bubble going from broken with max-w-[78%] to correct with max-w-[295px]. The observation was right; the conclusion was not. Craft never saw a percentage.

stx was compiling max-w-[78%] to maxWidth: 78 — points, not a percentage. The bubble really was 78 points wide, about 50 inside its padding, which is why the text broke mid-word. Swapping to 295px "fixed" it only because 295 points is a sensible width; it told me nothing about percentages. CraftNativeLength.resolve was correct the whole time, as was the max-content work in CraftNativeFlexLayout.measure that closed this issue the first time.

Fixed upstream in stx 0.2.521, which keeps an arbitrary percentage size a percentage. The bundle now contains maxWidth:"78%", which Craft parses as .percent(0.78) and resolves against the parent.

Verified on an iPhone 17 Pro, craft-native 0.0.173, with the app-side workaround removed:

  • Went 4.3 miles — one line, shrink-to-fit
  • a 130-character message — wraps between words, capped well short of the edge

Sorry for the noise on this one. The thing worth keeping from it is that the original max-content fix was necessary and is doing its job; what looked like it failing was a size arriving in the wrong unit.

Sign in to comment on this issue.